Skip to content

Commit ac22a64

Browse files
committed
fix(compiler): enable JSON mode + harden plan handling (closes #71)
Concept generation can fail in two ways when the LLM emits malformed JSON for the concepts plan: (a) the parser raises and the function returns with zero concepts written, (b) a structurally-wrong shape (nested list, bare strings) slips past the list fallback and crashes each per-concept task at `concept.get("title")`. Both paths previously exited via `[OK]`, leaving users to discover an empty `wiki/concepts/` on their own. - Pass `response_format={"type": "json_object"}` on the four LLM calls whose prompt requests a JSON object (summary, plan, concept create, concept update). Constrains the decoder so providers that support json mode — OpenAI, DeepSeek, Qwen, Kimi, GLM, MiniMax, Doubao — can no longer return prose. The existing "Return ONLY valid JSON" prompt language already satisfies the DeepSeek/Qwen "must mention json" requirement. - Add `_filter_concept_items` to drop non-dict entries from the plan before they reach `_gen_create` / `_gen_update`. Logs the dropped count and the offending types so the cause is diagnosable. - Include the first 500 chars of `plan_raw` in the parse-failure WARNING (was DEBUG-only). Print a stdout `[WARN]` line on both "plan unparseable" and "planned N, wrote K" so a silent regression no longer masquerades as `[OK]`.
1 parent 5e81196 commit ac22a64

1 file changed

Lines changed: 67 additions & 10 deletions

File tree

openkb/agent/compiler.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
# Prompt templates
3838
# ---------------------------------------------------------------------------
3939

40+
# JSON-mode hint for calls whose prompt asks the LLM to return a JSON object.
41+
# Most providers (OpenAI, DeepSeek, Qwen, Kimi, GLM, MiniMax, Doubao) accept
42+
# this kwarg and switch into a JSON-constrained decoding mode; providers that
43+
# don't will either ignore it or raise BadRequestError (caller's choice).
44+
# DeepSeek/Qwen also require the prompt itself to mention "json", which the
45+
# templates below already satisfy.
46+
_JSON_RESPONSE_FORMAT = {"type": "json_object"}
47+
4048
_SYSTEM_TEMPLATE = """\
4149
You are OpenKB's wiki compilation agent for a personal knowledge base.
4250
@@ -297,6 +305,28 @@ def _parse_json(text: str) -> list | dict:
297305
return result
298306

299307

308+
def _filter_concept_items(items: list, label: str) -> list[dict]:
309+
"""Keep only dict items; warn about anything else.
310+
311+
The concepts-plan prompt asks for ``[{"name": ..., "title": ...}, ...]``
312+
but LLMs occasionally emit nested lists or bare strings. Letting those
313+
through crashes ``_gen_create`` at ``concept.get("title")`` and silently
314+
loses every concept in the batch (issue #71).
315+
"""
316+
if not isinstance(items, list):
317+
logger.warning("concepts plan: %s was %s, expected list — dropping",
318+
label, type(items).__name__)
319+
return []
320+
valid = [c for c in items if isinstance(c, dict)]
321+
if len(valid) < len(items):
322+
bad_types = sorted({type(c).__name__ for c in items if not isinstance(c, dict)})
323+
logger.warning(
324+
"concepts plan: dropped %d malformed %s item(s) (types: %s)",
325+
len(items) - len(valid), label, ", ".join(bad_types),
326+
)
327+
return valid
328+
329+
300330
# ---------------------------------------------------------------------------
301331
# File I/O helpers
302332
# ---------------------------------------------------------------------------
@@ -911,7 +941,7 @@ async def _compile_concepts(
911941
{"role": "user", "content": _CONCEPTS_PLAN_USER.format(
912942
concept_briefs=concept_briefs,
913943
)},
914-
], "concepts-plan", max_tokens=1024)
944+
], "concepts-plan", max_tokens=1024, response_format=_JSON_RESPONSE_FORMAT)
915945

916946
def _write_v1_summary_stripped() -> None:
917947
"""Fallback writer for the v1 summary on early-return paths.
@@ -935,20 +965,34 @@ def _write_v1_summary_stripped() -> None:
935965
try:
936966
parsed = _parse_json(plan_raw)
937967
except (json.JSONDecodeError, ValueError) as exc:
938-
logger.warning("Failed to parse concepts plan: %s", exc)
939-
logger.debug("Raw: %s", plan_raw)
968+
# Include raw output preview in the WARNING itself (was DEBUG-only).
969+
# Without this, users hitting LLM gibberish have no way to diagnose
970+
# why no concepts were generated — see issue #71.
971+
preview = plan_raw[:500] + ("..." if len(plan_raw) > 500 else "")
972+
logger.warning(
973+
"Failed to parse concepts plan: %s. Raw output (first 500 chars): %r",
974+
exc, preview,
975+
)
976+
sys.stdout.write(
977+
f" [WARN] concepts plan unparseable for {doc_name} — "
978+
f"no concept pages generated. See log above.\n"
979+
)
980+
sys.stdout.flush()
940981
if rewrite_summary:
941982
_write_v1_summary_stripped()
942983
_update_index(wiki_dir, doc_name, [], doc_brief=doc_brief, doc_type=doc_type)
943984
return
944985

945-
# Fallback: if LLM returns a flat list, treat all items as "create"
986+
# Fallback: if LLM returns a flat list, treat all items as "create".
987+
# Validate each item is a dict — without this, a nested list like
988+
# [[{...}]] crashes _gen_create at `concept.get("title")` (issue #71).
946989
if isinstance(parsed, list):
947-
plan = {"create": parsed, "update": [], "related": []}
990+
plan = {"create": _filter_concept_items(parsed, "list"),
991+
"update": [], "related": []}
948992
else:
949993
plan = {
950-
"create": parsed.get("create", []),
951-
"update": parsed.get("update", []),
994+
"create": _filter_concept_items(parsed.get("create", []), "create"),
995+
"update": _filter_concept_items(parsed.get("update", []), "update"),
952996
"related": parsed.get("related", []),
953997
}
954998

@@ -1010,7 +1054,7 @@ async def _gen_create(concept: dict) -> tuple[str, str, bool, str]:
10101054
title=title, doc_name=doc_name,
10111055
update_instruction="",
10121056
)},
1013-
], f"concept: {name}")
1057+
], f"concept: {name}", response_format=_JSON_RESPONSE_FORMAT)
10141058
try:
10151059
parsed = _parse_json(raw)
10161060
brief = parsed.get("brief", "")
@@ -1042,7 +1086,7 @@ async def _gen_update(concept: dict) -> tuple[str, str, bool, str]:
10421086
title=title, doc_name=doc_name,
10431087
existing_content=existing_content,
10441088
)},
1045-
], f"update: {name}")
1089+
], f"update: {name}", response_format=_JSON_RESPONSE_FORMAT)
10461090
try:
10471091
parsed = _parse_json(raw)
10481092
brief = parsed.get("brief", "")
@@ -1077,6 +1121,18 @@ async def _gen_update(concept: dict) -> tuple[str, str, bool, str]:
10771121
if brief:
10781122
concept_briefs_map[safe_name] = brief
10791123

1124+
# Surface partial/total failure prominently: WARNING logs are easy to
1125+
# miss in long compile output, and the [OK] line at the end of `add`
1126+
# is unconditional. Issue #71: silent loss of all concepts looked
1127+
# like success to the user.
1128+
written = len(pending_writes)
1129+
if written < total:
1130+
sys.stdout.write(
1131+
f" [WARN] {total} concept(s) planned but only {written} written "
1132+
f"for {doc_name} — check warnings above.\n"
1133+
)
1134+
sys.stdout.flush()
1135+
10801136
# Strip unresolved wikilinks from concept bodies before writing. The
10811137
# whitelist includes existing files + this round's planned slugs +
10821138
# the summary for this document.
@@ -1212,7 +1268,8 @@ async def compile_short_doc(
12121268
# for the plan + concept-generation calls, then rewritten into a final
12131269
# v2 (with a whitelist of known wikilink targets) inside
12141270
# _compile_concepts before being written to disk.
1215-
summary_raw = _llm_call(model, [system_msg, doc_msg], "summary")
1271+
summary_raw = _llm_call(model, [system_msg, doc_msg], "summary",
1272+
response_format=_JSON_RESPONSE_FORMAT)
12161273
try:
12171274
summary_parsed = _parse_json(summary_raw)
12181275
doc_brief = summary_parsed.get("brief", "")

0 commit comments

Comments
 (0)