diff --git a/doc/architecture/SPEC/kb_service.md b/doc/architecture/SPEC/kb_service.md index 43de1b98..e9073758 100644 --- a/doc/architecture/SPEC/kb_service.md +++ b/doc/architecture/SPEC/kb_service.md @@ -298,3 +298,6 @@ diff 时保留原摘录(证据不会因此变得更"好过")。只扩展带 `CALIBRATION_KEYS` = `knowledge_lifecycle.calibration_set`。`check_flip_to_shadow` 另要求 head 为 `enabled: true` 且 `mode` 为 shadow(缺省即 shadow)。知识 L1 的白名单只含 `knowledge/`,adapter 这一处改动由它单独校验; 调用方还需在 head adapter 上跑 `config.parse_lifecycle`。测试:`test_kb_lifecycle_flip.py`。 + +## 2026-09-30 模型回复解析 +`models.parse_json_object` 取裸 JSON 对象或最后一个 ```json 围栏块,用 `json.loads(strict=False)`:字符串里的原始控制字符(多行值里的换行、制表符,例如图示)按数据接受,不再把结构正确的回答判为 `ModelUnavailable`。测试:`test_kb_intake_gate.py`。 diff --git a/src/infermatrix_copilot/kb_service/models.py b/src/infermatrix_copilot/kb_service/models.py index 8c5f4094..df58387f 100644 --- a/src/infermatrix_copilot/kb_service/models.py +++ b/src/infermatrix_copilot/kb_service/models.py @@ -64,12 +64,15 @@ class ModelReply: def parse_json_object(text: str) -> dict: - """The reply's JSON object: a bare object, or the last fenced ```json block.""" + """The reply's JSON object: a bare object, or the last fenced ```json block. + Raw control characters inside strings (a newline in a multi-line value, + e.g. a diagram) are accepted: models write them, and they are data, not + structure (``strict=False``).""" text = text.strip() candidates = [text] + [m.group(1) for m in _JSON_FENCE.finditer(text)][::-1] for candidate in candidates: try: - value = json.loads(candidate) + value = json.loads(candidate, strict=False) except ValueError: continue if isinstance(value, dict): diff --git a/test/test_kb_intake_gate.py b/test/test_kb_intake_gate.py index a467b252..094d086d 100644 --- a/test/test_kb_intake_gate.py +++ b/test/test_kb_intake_gate.py @@ -92,6 +92,17 @@ def test_default_roles_are_pinned_and_cross_family(monkeypatch): roles_from_env() +def test_parse_json_object_accepts_raw_newlines_inside_strings(): + """Regression (kb init pilot, 2026-09-30): the generator wrote an ASCII + diagram with literal newlines inside a JSON string; strict parsing + rejected a well-formed answer and the stage stopped.""" + fenced = '```json\n{"architecture_md": "A\n -> B\n\tC", "n": 1}\n```' + assert parse_json_object(fenced) == {"architecture_md": "A\n -> B\n\tC", "n": 1} + assert parse_json_object('{"a": "x\ny"}') == {"a": "x\ny"} + with pytest.raises(ModelUnavailable): + parse_json_object('{"a": "unterminated\n') + + def test_parse_json_object_and_unavailable(): assert parse_json_object('text\n```json\n{"a": 1}\n```') == {"a": 1} with pytest.raises(ModelUnavailable):