diff --git a/Administrative_Reform_1/04_Orders/ORDER_193_R_RESULT_TO_NODE3_VESSEL_MATERIAL_V0.md b/Administrative_Reform_1/04_Orders/ORDER_193_R_RESULT_TO_NODE3_VESSEL_MATERIAL_V0.md index e0fc2b8..0967a49 100644 --- a/Administrative_Reform_1/04_Orders/ORDER_193_R_RESULT_TO_NODE3_VESSEL_MATERIAL_V0.md +++ b/Administrative_Reform_1/04_Orders/ORDER_193_R_RESULT_TO_NODE3_VESSEL_MATERIAL_V0.md @@ -2,9 +2,11 @@ ## Status -Prepared on 2026-07-03 as the next narrow MVP after the Vessel graph memory and R traversal baseline was merged to `main`. +Implemented on 2026-07-03 after the user approved the narrow MVP. -Do not implement this order until the user explicitly approves implementation. +Implementation record: + +- `Administrative_Reform_1/05_Execution_Records/order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md` ## Trigger diff --git a/Administrative_Reform_1/04_Orders/README.md b/Administrative_Reform_1/04_Orders/README.md index 4fd4da2..308813e 100644 --- a/Administrative_Reform_1/04_Orders/README.md +++ b/Administrative_Reform_1/04_Orders/README.md @@ -156,7 +156,7 @@ `ORDER_175`부터 `ORDER_192`까지는 Vessel-backed R read packet, R1/R2/R3 one-step traversal, 계층 surface, multi-step traversal, summary-before-raw 정책, raw original cap, R traverse live audit, token summary 하위 summary expansion, R2 branch role surface 안정화, R1 user question anchor copy로 이어지는 R루프 실전성 검증 작업이다. -`ORDER_193`은 R traversal 결과를 node_3가 사용할 수 있는 read-only Vessel material로 전달하되, 아직 정상 live answer route가 완전히 R-powered인 척하지 않게 하는 다음 MVP 발주서다. +`ORDER_193`은 R traversal 결과를 node_3가 사용할 수 있는 read-only Vessel material로 전달하되, 아직 정상 live answer route가 완전히 R-powered인 척하지 않게 하는 MVP로 구현되었다. ## 임시 발주서 diff --git a/Administrative_Reform_1/05_Execution_Records/README.md b/Administrative_Reform_1/05_Execution_Records/README.md index 82d8c02..ba3f56a 100644 --- a/Administrative_Reform_1/05_Execution_Records/README.md +++ b/Administrative_Reform_1/05_Execution_Records/README.md @@ -185,3 +185,4 @@ - [order_191_r2_source_ingest_branch_selection_stability_2026_07_03_001.md](order_191_r2_source_ingest_branch_selection_stability_2026_07_03_001.md) - [order_192_r1_user_question_anchor_copy_2026_07_03_001.md](order_192_r1_user_question_anchor_copy_2026_07_03_001.md) - [public_demo_docs_and_order_193_draft_2026_07_03_001.md](public_demo_docs_and_order_193_draft_2026_07_03_001.md) +- [order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md](order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md) diff --git a/Administrative_Reform_1/05_Execution_Records/order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md b/Administrative_Reform_1/05_Execution_Records/order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md new file mode 100644 index 0000000..ec4e757 --- /dev/null +++ b/Administrative_Reform_1/05_Execution_Records/order_193_r_result_to_node3_vessel_material_implementation_2026_07_03_001.md @@ -0,0 +1,62 @@ +# ORDER 193 R Result To Node3 Vessel Material Implementation 2026-07-03 001 + +## Summary + +Implemented ORDER 193: R traversal output can now be copied into `Node3InputBriefFrame` as read-only Vessel graph memory material. + +This does not enable normal `route=R` live answering by default. It only makes an already recorded Vessel R traversal result visible to node_3 with explicit source/status boundaries. + +## Code Changes + +- Added `Node3VesselRMaterialItem` and `Node3VesselRMaterial` schema frames. +- Added `vessel_r_material_status`, `vessel_r_material_count`, `vessel_r_material_source_data_ids`, and `vessel_r_material` to `Node3InputBriefFrame`. +- Added a node_2 handoff builder that reads the latest `r_loop:vessel_traverse_result` plus its `r_loop:vessel_read_packet` and copies matching Vessel graph records into node_3 material. +- Added node_3 grounding block/runtime visibility for Vessel R material. +- Added node_3 prompt boundary: Vessel R material is graph memory material, not `read_doc`, `read_code_file`, or normal document context. +- Added node_4 prompt/code guard direction for failed/partial Vessel R material and raw `graph:*` ID exposure. +- Added focused ORDER 193 tests. + +## Boundary + +- Code copies R traversal status and pre-existing graph summary text. +- Code does not invent semantic relevance. +- Code does not summarize Vessel material. +- Code does not merge R material into L document material. +- R material remains separate from `read_doc`, source-code read, recent memory context, and document context pack. + +## Verification + +```powershell +python -m compileall songryeon_core main.py tests\test_order_193_r_result_to_node3_vessel_material.py +# passed + +python -m pytest tests/test_order_193_r_result_to_node3_vessel_material.py -q +# 3 passed + +python main.py smoke-test +# SMOKE_TEST_OK + +python -m pytest -q --maxfail=1 +# 282 passed in 678.17s + +git diff --check +# passed +``` + +Note: an earlier full `python -m pytest` run hit a 10-minute timeout before output completion. Re-running with a longer limit passed. + +## Test Coverage Added + +- Successful Vessel R traversal creates node_3 Vessel material. +- Failed Vessel R traversal is preserved as failed with no fake material items. +- node_3 LLM payload receives safe material labels/summary text without raw `graph_node_id`. +- node_4 code guard blocks a final answer that claims Vessel R traversal succeeded when the material says failed. + +## Remaining Non-Goals + +- No default `route=R`. +- No full R-powered live answer route. +- No Neo4j schema change. +- No new graph write path. +- No raw original read cap increase. +- No R material relevance heuristic. diff --git a/songryeon_core/core/schemas.py b/songryeon_core/core/schemas.py index a9d45b9..4ec03e2 100644 --- a/songryeon_core/core/schemas.py +++ b/songryeon_core/core/schemas.py @@ -2192,6 +2192,55 @@ class Node3RLoopResultMaterial: attitude_hint: str = "r_loop_partial_or_skeleton_only" +@dataclass +class Node3VesselRMaterialItem: + """Vessel R traversal이 고른 graph material 하나를 node_3용으로 안전하게 표시한다.""" + + # 절대 정보: LLM payload에는 이 값 대신 material_label을 우선 노출한다. + graph_node_id: str + # 사용자-facing 안전 표지. 예: Vessel R material #1. + material_label: str + # summary, raw_original, graph_node 중 하나. + material_kind: str + display_name: str + node_kind: str + data_kind: str + summary_depth: int + source_leaf_count: int + source_summary_count: int + # 요약 node가 이미 가진 정보 등급. raw/graph node에는 absolute를 쓴다. + info_class: str + generated_by: str + # 이미 graph memory에 저장된 요약 text. code가 새 요약을 만들지 않는다. + summary_text: str = "" + summary_text_char_count: int = 0 + text_payload_status: str = "metadata_only" + source_data_ids: list[str] = field(default_factory=list) + + +@dataclass +class Node3VesselRMaterial: + """Vessel R traversal 결과를 node_3에게 read-only graph memory 재료로 넘기는 장부.""" + + source_data_id: str + material_status: str + traverse_status: str + r_loop_task_status: str + failure_type: str | None + failure_reason: str | None + traversal_path_count: int + selected_graph_node_ids: list[str] + inspected_graph_node_ids: list[str] + summary_material_count: int + raw_original_material_count: int + material_items: list[Node3VesselRMaterialItem] = field(default_factory=list) + generated_by: str = "CODE:NODE3_VESSEL_R_MATERIAL_BUILDER" + info_class: str = "absolute" + semantic_judgement_status: str = "not_run" + source_data_ids: list[str] = field(default_factory=list) + source_trace_ids: list[str] = field(default_factory=list) + + @dataclass class Node3SourceCodeSymbol: """read_code_file 원문에서 code가 문법적으로 확인한 top-level symbol.""" @@ -2289,6 +2338,10 @@ class Node3InputBriefFrame: material_policy_semantic_judgement_status: str = "not_run" runtime_tasks: list[Node3BriefRuntimeTask] = field(default_factory=list) r_loop_result_material: Node3RLoopResultMaterial | None = None + vessel_r_material_status: str = "not_recorded" + vessel_r_material_count: int = 0 + vessel_r_material_source_data_ids: list[str] = field(default_factory=list) + vessel_r_material: Node3VesselRMaterial | None = None l_loop_return_summary_frame_id: str | None = None l_loop_task_status: str = "not_recorded" l_loop_failure_level: str = "none" @@ -2457,6 +2510,7 @@ def validate_node3_input_brief_frame(frame: Node3InputBriefFrame) -> None: raise ValueError( "Node3InputBriefFrame.source_data_ids must include R loop result source_data_id" ) + _validate_node3_vessel_r_material_fields(frame) _validate_node3_l_loop_result_fields(frame) _validate_node3_answer_basis_fields(frame) for rule in frame.reporting_rules: @@ -2665,6 +2719,142 @@ def _validate_node3_r_loop_result_material(material: Node3RLoopResultMaterial) - raise ValueError(f"Node3RLoopResultMaterial.{field_name} must not be negative") +def _validate_node3_vessel_r_material_fields(frame: Node3InputBriefFrame) -> None: + if frame.vessel_r_material_status not in {"not_recorded", "present", "failed"}: + raise ValueError( + "unknown Node3InputBriefFrame.vessel_r_material_status: " + f"{frame.vessel_r_material_status}" + ) + if not isinstance(frame.vessel_r_material_count, int): + raise TypeError("Node3InputBriefFrame.vessel_r_material_count must be an integer") + if frame.vessel_r_material_count < 0: + raise ValueError("Node3InputBriefFrame.vessel_r_material_count must not be negative") + for data_id in frame.vessel_r_material_source_data_ids: + if not data_id: + raise ValueError( + "Node3InputBriefFrame.vessel_r_material_source_data_ids must not contain empty values" + ) + if frame.vessel_r_material is None: + if frame.vessel_r_material_status != "not_recorded": + raise ValueError("vessel_r_material_status requires vessel_r_material") + if frame.vessel_r_material_count != 0: + raise ValueError("vessel_r_material_count must be 0 without vessel_r_material") + if frame.vessel_r_material_source_data_ids: + raise ValueError( + "vessel_r_material_source_data_ids must be empty without vessel_r_material" + ) + return + + _validate_node3_vessel_r_material(frame.vessel_r_material) + if frame.vessel_r_material_status != frame.vessel_r_material.material_status: + raise ValueError("vessel_r_material_status must mirror vessel_r_material.material_status") + if frame.vessel_r_material_count != len(frame.vessel_r_material.material_items): + raise ValueError("vessel_r_material_count must mirror material_items length") + if frame.vessel_r_material_source_data_ids != frame.vessel_r_material.source_data_ids: + raise ValueError( + "vessel_r_material_source_data_ids must mirror vessel_r_material.source_data_ids" + ) + for data_id in frame.vessel_r_material.source_data_ids: + if data_id not in frame.source_data_ids: + raise ValueError( + "Node3InputBriefFrame.source_data_ids must include vessel R material source_data_ids" + ) + + +def _validate_node3_vessel_r_material(material: Node3VesselRMaterial) -> None: + for field_name, value in { + "source_data_id": material.source_data_id, + "material_status": material.material_status, + "traverse_status": material.traverse_status, + "r_loop_task_status": material.r_loop_task_status, + "generated_by": material.generated_by, + "info_class": material.info_class, + "semantic_judgement_status": material.semantic_judgement_status, + }.items(): + if not value: + raise ValueError(f"Node3VesselRMaterial.{field_name} must not be empty") + if material.material_status not in {"present", "failed"}: + raise ValueError(f"unknown Node3VesselRMaterial.material_status: {material.material_status}") + if material.traverse_status not in {"completed", "failed", "not_run"}: + raise ValueError(f"unknown Node3VesselRMaterial.traverse_status: {material.traverse_status}") + if material.r_loop_task_status not in {"not_run", "sufficient", "partial", "failed"}: + raise ValueError( + f"unknown Node3VesselRMaterial.r_loop_task_status: {material.r_loop_task_status}" + ) + if material.info_class != "absolute": + raise ValueError("Node3VesselRMaterial.info_class must be absolute") + if material.semantic_judgement_status != "not_run": + raise ValueError("Node3VesselRMaterial.semantic_judgement_status must be not_run") + for field_name, value in { + "traversal_path_count": material.traversal_path_count, + "summary_material_count": material.summary_material_count, + "raw_original_material_count": material.raw_original_material_count, + }.items(): + if not isinstance(value, int): + raise TypeError(f"Node3VesselRMaterial.{field_name} must be an integer") + if value < 0: + raise ValueError(f"Node3VesselRMaterial.{field_name} must not be negative") + if material.traversal_path_count != len(material.inspected_graph_node_ids): + raise ValueError("Node3VesselRMaterial.traversal_path_count must mirror inspected ids") + if material.summary_material_count != sum( + 1 for item in material.material_items if item.material_kind == "summary" + ): + raise ValueError("Node3VesselRMaterial.summary_material_count must mirror summary items") + if material.raw_original_material_count != sum( + 1 for item in material.material_items if item.material_kind == "raw_original" + ): + raise ValueError( + "Node3VesselRMaterial.raw_original_material_count must mirror raw original items" + ) + if material.material_status == "failed" and material.material_items: + raise ValueError("failed Node3VesselRMaterial must not contain material_items") + if material.source_data_id not in material.source_data_ids: + raise ValueError("Node3VesselRMaterial.source_data_ids must include source_data_id") + for item in material.material_items: + _validate_node3_vessel_r_material_item(item) + + +def _validate_node3_vessel_r_material_item(item: Node3VesselRMaterialItem) -> None: + for field_name, value in { + "graph_node_id": item.graph_node_id, + "material_label": item.material_label, + "material_kind": item.material_kind, + "display_name": item.display_name, + "node_kind": item.node_kind, + "data_kind": item.data_kind, + "info_class": item.info_class, + "generated_by": item.generated_by, + "text_payload_status": item.text_payload_status, + }.items(): + if not value: + raise ValueError(f"Node3VesselRMaterialItem.{field_name} must not be empty") + if item.material_kind not in {"summary", "raw_original", "graph_node"}: + raise ValueError(f"unknown Node3VesselRMaterialItem.material_kind: {item.material_kind}") + if item.info_class not in {"absolute", "relative", "mixed"}: + raise ValueError(f"unknown Node3VesselRMaterialItem.info_class: {item.info_class}") + if item.text_payload_status not in {"included_summary_text", "metadata_only"}: + raise ValueError( + "unknown Node3VesselRMaterialItem.text_payload_status: " + f"{item.text_payload_status}" + ) + for field_name, value in { + "summary_depth": item.summary_depth, + "source_leaf_count": item.source_leaf_count, + "source_summary_count": item.source_summary_count, + "summary_text_char_count": item.summary_text_char_count, + }.items(): + if not isinstance(value, int): + raise TypeError(f"Node3VesselRMaterialItem.{field_name} must be an integer") + if value < 0: + raise ValueError(f"Node3VesselRMaterialItem.{field_name} must not be negative") + if item.text_payload_status == "included_summary_text" and not item.summary_text: + raise ValueError("included_summary_text requires summary_text") + if item.text_payload_status == "metadata_only" and item.summary_text: + raise ValueError("metadata_only item must not contain summary_text") + if item.graph_node_id not in item.source_data_ids: + raise ValueError("Node3VesselRMaterialItem.source_data_ids must include graph_node_id") + + def _validate_node3_l3_document_summary_material( material: Node3L3DocumentSummaryMaterial, ) -> None: diff --git a/songryeon_core/nodes/node_2_handoff.py b/songryeon_core/nodes/node_2_handoff.py index aac5bfc..dc3ff65 100644 --- a/songryeon_core/nodes/node_2_handoff.py +++ b/songryeon_core/nodes/node_2_handoff.py @@ -18,6 +18,8 @@ Node3MaterialDeliveryPolicyFrame, Node3MemorySelectionMaterial, Node3RLoopResultMaterial, + Node3VesselRMaterial, + Node3VesselRMaterialItem, Node3SourceCodeOutline, Node3SourceCodeSymbol, Node3SelectedRecentMemoryContext, @@ -42,6 +44,9 @@ SELECTED_MEMORY_RAW_USER_TEXT_MAX_CHARS = 800 SELECTED_MEMORY_RAW_ASSISTANT_TEXT_MAX_CHARS = 1200 SELECTED_MEMORY_MAX_ITEMS = 3 +R_LOOP_VESSEL_TRAVERSE_RESULT_DATA_TYPE = "r_loop:vessel_traverse_result" +R_LOOP_VESSEL_READ_PACKET_DATA_TYPE = "r_loop:vessel_read_packet" +NODE3_VESSEL_R_MATERIAL_SUMMARY_MAX_CHARS = 1200 def selected_recent_memory_context_frame_data_id(selection_frame_id: str) -> str: @@ -512,6 +517,7 @@ def record_node3_input_brief( assigned_model_by_node or {}, ) r_loop_result_material = _node3_r_loop_result_material(data_store) + vessel_r_material = _node3_vessel_r_material(data_store) # 학습 메모: node_3 brief는 claim의 의미 등급을 다시 판단하지 않는다. # node_2 boundary가 붙인 relative/mixed 라벨을 사용자 답변 재료까지 그대로 운반한다. allowed_claims = [ @@ -543,6 +549,14 @@ def record_node3_input_brief( ) has_selected_memory_context = bool(selected_recent_memory_contexts) has_l3_document_summary_material = bool(l3_document_summaries) + has_r_loop_result_material = r_loop_result_material is not None + has_vessel_r_material = ( + vessel_r_material is not None + and ( + vessel_r_material.material_status == "present" + or bool(vessel_r_material.material_items) + ) + ) if ( not read_documents and not allowed_claims @@ -550,6 +564,8 @@ def record_node3_input_brief( and not has_selected_memory_material and not has_selected_memory_context and not has_l3_document_summary_material + and not has_r_loop_result_material + and not has_vessel_r_material ): insufficiency_reasons.append("no_document_or_claim_material") @@ -662,6 +678,22 @@ def record_node3_input_brief( ), runtime_tasks=runtime_tasks, r_loop_result_material=r_loop_result_material, + vessel_r_material_status=( + vessel_r_material.material_status + if vessel_r_material is not None + else "not_recorded" + ), + vessel_r_material_count=( + len(vessel_r_material.material_items) + if vessel_r_material is not None + else 0 + ), + vessel_r_material_source_data_ids=( + list(vessel_r_material.source_data_ids) + if vessel_r_material is not None + else [] + ), + vessel_r_material=vessel_r_material, l_loop_return_summary_frame_id=l_loop_return_summary_frame_id, l_loop_task_status=_text(l_loop_return_summary, "l_loop_task_status", fallback="not_recorded"), l_loop_failure_level=_text(l_loop_return_summary, "failure_level", fallback="none"), @@ -746,6 +778,9 @@ def record_node3_input_brief( "answer_basis_mode=mixed_or_uncertain이면 출처 묶음, 부분 근거, 불확실성을 드러내고 부족한 근거를 지어내지 않는다.", "R route 실험 결과가 있으면 graph memory skeleton 실행 상태로만 말하고, 완전한 장기기억 탐색 성공처럼 말하지 않는다.", "R loop result material은 R return summary 장부에서 복사된 절대 상태이며, R1/R2/R3 의미 판단이 실행됐다는 뜻이 아니다.", + "Vessel R material은 graph memory 탐색 결과에서 code가 복사한 read-only 재료이며, read_doc 문서나 read_code_file 원문이 아니다.", + "Vessel R material의 summary_text는 graph memory에 이미 저장된 요약 재료이고, code가 새로 작성한 요약이 아니다.", + "Vessel R material status가 present가 아니거나 r_loop_task_status가 sufficient가 아니면 R 탐색 성공으로 단정하지 않는다.", ], insufficiency_reasons=insufficiency_reasons, source_trace_ids=_unique_strings([*input_trace_ids, *runtime_trace_ids]), @@ -773,6 +808,11 @@ def record_node3_input_brief( r_loop_result_material.source_data_id if r_loop_result_material is not None else None, + *( + vessel_r_material.source_data_ids + if vessel_r_material is not None + else [] + ), *runtime_data_ids, ] ), @@ -959,6 +999,183 @@ def _r_loop_result_attitude_hint(payload: dict[str, object]) -> str: return "not_recorded" +def _node3_vessel_r_material( + data_store: DataStore, +) -> Node3VesselRMaterial | None: + data_id, payload = _latest_vessel_r_traverse_result(data_store) + if not data_id: + return None + + packet_id = _text(payload, "source_packet_id", fallback="") + read_packet_payload = _payload_by_id(data_store, packet_id) + traverse_status = _text(payload, "traverse_status", fallback="failed") + r_loop_task_status = _text(payload, "r_loop_task_status", fallback="failed") + selected_ids = _string_list(payload.get("selected_graph_node_ids")) + inspected_ids = _string_list(payload.get("inspected_graph_node_ids")) + source_trace_ids = _string_list(payload.get("source_trace_ids")) + material_status = "present" if traverse_status == "completed" else "failed" + + material_items: list[Node3VesselRMaterialItem] = [] + if material_status == "present" and read_packet_payload: + records_by_id = _vessel_read_packet_records_by_id(read_packet_payload) + material_ids = _unique_strings([*selected_ids, *inspected_ids]) + for graph_node_id in material_ids: + record = records_by_id.get(graph_node_id) + if record is None: + continue + item = _node3_vessel_r_material_item( + graph_node_id=graph_node_id, + record=record, + index=len(material_items) + 1, + ) + material_items.append(item) + + source_data_ids = _unique_strings( + [ + data_id, + packet_id or None, + _text(payload, "return_summary_frame_id", fallback="") or None, + *selected_ids, + *inspected_ids, + *[ + source_id + for item in material_items + for source_id in item.source_data_ids + ], + ] + ) + return Node3VesselRMaterial( + source_data_id=data_id, + material_status=material_status, + traverse_status=traverse_status, + r_loop_task_status=r_loop_task_status, + failure_type=_optional_text(payload.get("failure_type")), + failure_reason=_optional_text(payload.get("failure_reason")), + traversal_path_count=len(inspected_ids), + selected_graph_node_ids=selected_ids, + inspected_graph_node_ids=inspected_ids, + summary_material_count=sum( + 1 for item in material_items if item.material_kind == "summary" + ), + raw_original_material_count=sum( + 1 for item in material_items if item.material_kind == "raw_original" + ), + material_items=material_items, + source_data_ids=source_data_ids, + source_trace_ids=source_trace_ids, + ) + + +def _latest_vessel_r_traverse_result(data_store: DataStore) -> tuple[str | None, dict[str, object]]: + for record in reversed(data_store.list_records()): + if record.data_type != R_LOOP_VESSEL_TRAVERSE_RESULT_DATA_TYPE: + continue + if not isinstance(record.payload, dict): + continue + return record.data_id, record.payload + return None, {} + + +def _payload_by_id(data_store: DataStore, data_id: str) -> dict[str, object]: + if not data_id: + return {} + record = data_store.get_record(data_id) + if record is None or not isinstance(record.payload, dict): + return {} + return record.payload + + +def _vessel_read_packet_records_by_id( + read_packet_payload: dict[str, object], +) -> dict[str, dict[str, object]]: + records: dict[str, dict[str, object]] = {} + for record in _dict_list(read_packet_payload.get("entry_candidate_records")): + graph_node_id = record.get("candidate_node_id") + if isinstance(graph_node_id, str) and graph_node_id: + records[graph_node_id] = dict(record) + for record in _dict_list(read_packet_payload.get("summary_candidate_records")): + graph_node_id = record.get("summary_node_id") + if isinstance(graph_node_id, str) and graph_node_id: + records[graph_node_id] = dict(record) + return records + + +def _node3_vessel_r_material_item( + *, + graph_node_id: str, + record: dict[str, object], + index: int, +) -> Node3VesselRMaterialItem: + is_summary = isinstance(record.get("summary_node_id"), str) + node_kind = "summary" if is_summary else _text(record, "node_kind", fallback="graph_node") + candidate_kind = _text(record, "candidate_kind", fallback="") + material_kind = "graph_node" + if is_summary: + material_kind = "summary" + elif node_kind in {"raw_source", "raw_capsule"} or candidate_kind in { + "raw_source", + "raw_capsule", + }: + material_kind = "raw_original" + + summary_text = "" + summary_text_char_count = 0 + text_payload_status = "metadata_only" + if is_summary: + raw_summary_text = _text(record, "summary_text", fallback="") + summary_text = raw_summary_text[:NODE3_VESSEL_R_MATERIAL_SUMMARY_MAX_CHARS] + summary_text_char_count = _int(record, "summary_text_char_count") + if not summary_text_char_count and summary_text: + summary_text_char_count = len(summary_text) + text_payload_status = "included_summary_text" if summary_text else "metadata_only" + + item_source_data_ids = _unique_strings( + [ + graph_node_id, + *( + _string_list(record.get("source_data_ids")) + if is_summary + else [] + ), + *( + _string_list(record.get("source_graph_node_ids")) + if is_summary + else [] + ), + _text(record, "target_graph_node_id", fallback="") if is_summary else None, + ] + ) + return Node3VesselRMaterialItem( + graph_node_id=graph_node_id, + material_label=f"Vessel R material #{index}", + material_kind=material_kind, + display_name=( + _text(record, "summary_display_name", fallback="") + if is_summary + else _text(record, "display_name", fallback="") + ) or f"Vessel graph material #{index}", + node_kind=node_kind, + data_kind=_text(record, "data_kind", fallback="unknown"), + summary_depth=_int(record, "summary_depth"), + source_leaf_count=_int(record, "source_leaf_count"), + source_summary_count=_int(record, "source_summary_count"), + info_class=( + _text(record, "info_class", fallback="mixed") + if is_summary + else "absolute" + ), + generated_by=( + _text(record, "generated_by", fallback="unknown") + if is_summary + else "CODE:VESSEL_GRAPH_RECORD" + ), + summary_text=summary_text, + summary_text_char_count=summary_text_char_count, + text_payload_status=text_payload_status, + source_data_ids=item_source_data_ids, + ) + + def node3_brief_llm_payload(frame: Node3InputBriefFrame) -> dict[str, object]: """내부 ID를 제거한 node_3 LLM용 payload를 만든다.""" @@ -1195,6 +1412,9 @@ def node3_brief_llm_payload(frame: Node3InputBriefFrame) -> dict[str, object]: "r_loop_result": _node3_r_loop_result_llm_payload( frame.r_loop_result_material ), + "vessel_r_material": _node3_vessel_r_material_llm_payload( + frame.vessel_r_material + ), "runtime_task_sequence_note": ( "This sequence is captured before node_3 report generation. " "Later node_3 reporting and node_4 gatekeeping tasks may appear in the final runtime ledger." @@ -1255,6 +1475,53 @@ def _node3_r_loop_result_llm_payload( } +def _node3_vessel_r_material_llm_payload( + material: Node3VesselRMaterial | None, +) -> dict[str, object]: + if material is None: + return { + "status": "not_recorded", + "boundary": "No Vessel R traversal material was supplied to node_3.", + "items": [], + } + return { + "status": material.material_status, + "traverse_status": material.traverse_status, + "task_status": material.r_loop_task_status, + "failure_type": material.failure_type, + "failure_reason": material.failure_reason, + "traversal_path_count": material.traversal_path_count, + "summary_material_count": material.summary_material_count, + "raw_original_material_count": material.raw_original_material_count, + "generated_by": material.generated_by, + "info_class": material.info_class, + "semantic_judgement_status": material.semantic_judgement_status, + "boundary": ( + "This is read-only graph-memory material copied by CODE from a Vessel R traversal result. " + "It is not read_doc evidence, not read_code_file evidence, and not proof that the normal live answer route is R-powered. " + "Do not expose raw graph node ids." + ), + "items": [ + { + "material_label": item.material_label, + "material_kind": item.material_kind, + "display_name": item.display_name, + "node_kind": item.node_kind, + "data_kind": item.data_kind, + "summary_depth": item.summary_depth, + "source_leaf_count": item.source_leaf_count, + "source_summary_count": item.source_summary_count, + "info_class": item.info_class, + "generated_by": item.generated_by, + "summary_text": item.summary_text, + "summary_text_char_count": item.summary_text_char_count, + "text_payload_status": item.text_payload_status, + } + for item in material.material_items + ], + } + + def _node3_material_delivery_policy_llm_payload( frame: Node3InputBriefFrame, ) -> dict[str, object]: @@ -2547,6 +2814,10 @@ def _text(payload: dict[str, object], field_name: str, *, fallback: str) -> str: return fallback +def _optional_text(value: object) -> str | None: + return value if isinstance(value, str) and value.strip() else None + + def _int(payload: dict[str, object], field_name: str) -> int: value = payload.get(field_name) return value if isinstance(value, int) else 0 @@ -2558,6 +2829,12 @@ def _string_list(value: object) -> list[str]: return [item for item in value if isinstance(item, str) and item] +def _dict_list(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [dict(item) for item in value if isinstance(item, dict)] + + def _current_namespace_ids( data_ids: list[str], *, diff --git a/songryeon_core/nodes/node_3_reporter.py b/songryeon_core/nodes/node_3_reporter.py index 377c307..59ce058 100644 --- a/songryeon_core/nodes/node_3_reporter.py +++ b/songryeon_core/nodes/node_3_reporter.py @@ -159,6 +159,7 @@ def build_node3_grounding_block(brief_frame: Node3InputBriefFrame) -> str: f"- 현재 턴 실행 순서 자료: {len(brief_frame.runtime_tasks)}개", *_l_loop_grounding_lines(brief_frame), *_r_loop_grounding_lines(brief_frame), + *_vessel_r_grounding_lines(brief_frame), f"- 답변 근거 자세: {_answer_basis_mode_label(brief_frame.answer_basis_mode)}", f"- 재료 전달 정책: {_material_delivery_mode_label(brief_frame.material_delivery_mode)}", f"- 답변 한계: {_grounding_limit_text(brief_frame)}", @@ -281,6 +282,11 @@ def _grounding_limit_text(brief_frame: Node3InputBriefFrame) -> str: and brief_frame.r_loop_result_material.attitude_hint != "r_loop_sufficient" ): return "R route 실험 결과는 skeleton/부분 장부이므로 graph memory 탐색 성공으로 단정하지 않는다." + if ( + brief_frame.vessel_r_material is not None + and brief_frame.vessel_r_material.r_loop_task_status != "sufficient" + ): + return "Vessel R material이 있어도 R 탐색 상태가 충분하지 않으므로 graph memory 탐색 성공으로 단정하지 않는다." if brief_frame.insufficiency_reasons: return "자료 부족 신호가 있어 제공된 문서/허용 주장/현재 턴 실행 순서 자료 범위 안에서만 답한다." if brief_frame.answer_basis_mode == "absolute_first": @@ -315,6 +321,19 @@ def _r_loop_grounding_lines(brief_frame: Node3InputBriefFrame) -> list[str]: ] +def _vessel_r_grounding_lines(brief_frame: Node3InputBriefFrame) -> list[str]: + material = brief_frame.vessel_r_material + if material is None: + return [] + return [ + ( + "- Vessel R graph material: " + f"{brief_frame.vessel_r_material_count}개 / status={material.material_status} " + f"/ task={material.r_loop_task_status}" + ) + ] + + def _answer_basis_mode_label(mode: str) -> str: labels = { "absolute_first": "절대정보 우선", diff --git a/songryeon_core/nodes/node_4_gatekeeper.py b/songryeon_core/nodes/node_4_gatekeeper.py index 2beb445..eda120d 100644 --- a/songryeon_core/nodes/node_4_gatekeeper.py +++ b/songryeon_core/nodes/node_4_gatekeeper.py @@ -22,6 +22,9 @@ DOCUMENT_EVIDENCE_ROLE_CLAIM_MISMATCH = ( "CODE_STATUS:document_evidence_role_claim_mismatch" ) +VESSEL_R_MATERIAL_CLAIM_MISMATCH = ( + "CODE_STATUS:vessel_r_material_claim_mismatch" +) def run_node4_gatekeeper( @@ -72,6 +75,7 @@ def run_node4_gatekeeper( "최근 기억 발화가 selected_recent_memory_contexts 범위를 벗어나는지 확인한다.", "answer_basis_mode에 맞는 말하기 자세를 유지했는지 확인한다.", "L loop 실패/예산소진 신호를 검색 성공처럼 말하는지 확인한다.", + "Vessel R material 상태를 R traversal 성공처럼 과장하는지 확인한다.", ], }, trace_store=trace_store, @@ -168,6 +172,27 @@ def run_node4_gatekeeper( elif document_role_guard["status"] == "pass": checked_claims = _unique_strings([*checked_claims, "document_evidence_role_guard"]) + vessel_r_guard = _vessel_r_material_code_guard( + rendered_markdown=rendered_markdown, + brief_frame=brief_frame, + ) + if vessel_r_guard["status"] == "needs_revision": + if gate_status == "pass": + gate_status = "needs_revision" + if "CODE:VESSEL_R_MATERIAL_GUARD" not in gate_generation_source: + gate_generation_source = f"{gate_generation_source}+CODE:VESSEL_R_MATERIAL_GUARD" + for reason_code in vessel_r_guard["reason_codes"]: + reason = _append_reason(reason, reason_code) + checked_claims = _unique_strings([*checked_claims, "vessel_r_material_guard"]) + contradictions = _unique_strings( + [*contradictions, *vessel_r_guard["contradictions"]] + ) + revision_targets = _unique_strings( + [*revision_targets, *vessel_r_guard["revision_targets"]] + ) + elif vessel_r_guard["status"] == "pass": + checked_claims = _unique_strings([*checked_claims, "vessel_r_material_guard"]) + frame_source_trace_ids = list(input_ref) if llm_result.trace_event_id: frame_source_trace_ids.append(llm_result.trace_event_id) @@ -331,6 +356,73 @@ def _document_evidence_role_code_guard( } +def _vessel_r_material_code_guard( + *, + rendered_markdown: str, + brief_frame: Node3InputBriefFrame, +) -> dict[str, object]: + """Vessel R material의 상태와 보고문 속 명시 성공/역할 주장이 충돌하는지 검사한다.""" + + contradictions: list[str] = [] + revision_targets: list[str] = [] + material = brief_frame.vessel_r_material + material_status = brief_frame.vessel_r_material_status + task_status = material.r_loop_task_status if material is not None else "not_run" + + if _claims_vessel_r_success(rendered_markdown) and ( + material_status != "present" or task_status != "sufficient" + ): + contradictions.append( + "vessel_r_success_claim_without_sufficient_material:" + f"status_{material_status}_task_{task_status}" + ) + revision_targets.append( + "Vessel/R 탐색 상태가 sufficient가 아니면 성공으로 단정하지 않는다." + ) + + if _claims_vessel_r_as_document_evidence(rendered_markdown): + contradictions.append("vessel_r_material_claimed_as_document_evidence") + revision_targets.append( + "Vessel R graph material과 read_doc/read_code_file/document context 근거를 분리해 말한다." + ) + + graph_id_leak_count = len(re.findall(r"graph:[A-Za-z0-9_:\-]+", rendered_markdown)) + if graph_id_leak_count: + contradictions.append(f"vessel_r_graph_node_id_leak_count:{graph_id_leak_count}") + revision_targets.append("최종 답변에서 raw graph node ID를 제거한다.") + + unique_contradictions = _unique_strings(contradictions) + reason_codes = [VESSEL_R_MATERIAL_CLAIM_MISMATCH] if unique_contradictions else [] + return { + "status": "needs_revision" if unique_contradictions else "pass", + "reason_codes": reason_codes, + "contradictions": unique_contradictions, + "revision_targets": _unique_strings(revision_targets), + } + + +def _claims_vessel_r_success(rendered_markdown: str) -> bool: + patterns = [ + r"Vessel\s*/?\s*R\s*(?:traversal|탐색).{0,24}(?:succeeded|success|성공|충분)", + r"R\s*(?:traversal|탐색).{0,24}(?:succeeded|success|성공|충분)", + r"graph\s*memory\s*(?:traversal|탐색).{0,24}(?:succeeded|success|성공|충분)", + r"그래프\s*기억\s*탐색.{0,24}(?:성공|충분)", + ] + return any(re.search(pattern, rendered_markdown, flags=re.IGNORECASE) for pattern in patterns) + + +def _claims_vessel_r_as_document_evidence(rendered_markdown: str) -> bool: + for line in rendered_markdown.splitlines(): + lowered = line.lower() + if not ("vessel" in lowered or "r " in lowered or "r루프" in line or "r 탐색" in line): + continue + if "read_doc" in lowered or "read_code_file" in lowered: + return True + if re.search(r"(문서\s*context|문서\s*근거|읽은\s*문서)", line): + return True + return False + + def _claims_explicit_read_doc_role( *, rendered_markdown: str, diff --git a/songryeon_core/prompts/node_3_reporter_v0.md b/songryeon_core/prompts/node_3_reporter_v0.md index 682e5ad..161ad4b 100644 --- a/songryeon_core/prompts/node_3_reporter_v0.md +++ b/songryeon_core/prompts/node_3_reporter_v0.md @@ -65,6 +65,11 @@ Rules: - If `r_loop_result.status` is `present`, treat it as a code-copied R return summary ledger, not as proof that full R graph traversal succeeded. - If `r_loop_result.task_status` is not `sufficient`, clearly state that the R route produced an experimental skeleton/partial result when you mention it. - Do not claim R1/R2/R3 semantic traversal ran unless the supplied R material explicitly says so. +- If `vessel_r_material.status` is `present`, treat it as read-only graph-memory material copied from Vessel R traversal. +- `vessel_r_material` is not `read_doc` evidence, not `read_code_file` evidence, and not a normal document context. +- If `vessel_r_material.task_status` is not `sufficient`, do not claim Vessel/R traversal succeeded; say the material is partial or limited. +- Use `vessel_r_material.items[].material_label` and `display_name` as safe labels. Do not expose raw graph node IDs. +- If you rely on `vessel_r_material.items[].summary_text`, say it is graph-memory summary material and preserve its `info_class` boundary. - Do not expose R route raw internal IDs or graph node IDs in user-facing prose. - Write in Korean. - Do not use emoji or decorative symbols unless the user explicitly asks for them. diff --git a/songryeon_core/prompts/node_4_gatekeeper_v0.md b/songryeon_core/prompts/node_4_gatekeeper_v0.md index 2b89f33..6a4e18f 100644 --- a/songryeon_core/prompts/node_4_gatekeeper_v0.md +++ b/songryeon_core/prompts/node_4_gatekeeper_v0.md @@ -27,8 +27,13 @@ Rules: - Treat supplied `selected_recent_memory_contexts` as the only allowed grounding material for previous-conversation utterance claims. - Treat supplied `node3_input_brief.answer_basis` as the answer posture chosen by node_2. - Treat supplied `node3_input_brief.l_loop_result` as the checkable L search goal status. +- Treat supplied `node3_input_brief.vessel_r_material` as the only checkable Vessel/R graph-memory material. - If `l_loop_result.attitude_hint` is `l_loop_budget_exhausted` or `l_loop_partial_or_failed`, the report must not claim or imply that the L search goal succeeded. - If packed/read documents are supplied after an L failure signal, allow the report to use those documents, but require it to preserve the distinction between usable material and L search-goal success. +- If `vessel_r_material.status` is `not_recorded` or `failed`, the report must not claim Vessel/R traversal succeeded. +- If `vessel_r_material.task_status` is not `sufficient`, the report must not claim full graph-memory traversal success. +- If the report describes Vessel/R graph material as `read_doc`, `read_code_file`, or normal document context evidence, mark `needs_revision`. +- If the report exposes raw `graph:*` node IDs, mark `needs_revision`. - If `answer_basis_mode` is `absolute_first` and the report strongly asserts ungrounded guesses, mark `needs_revision`. - If `answer_basis_mode` is `mixed_or_uncertain` and the report does not expose limits, partial evidence, or uncertainty, mark `needs_revision`. - If `answer_basis_mode` is `relative_allowed`, allow interpretation or advice, but still mark false-looking absolute assertions outside the brief as `needs_revision`. diff --git a/songryeon_core/runtime/terminal_view.py b/songryeon_core/runtime/terminal_view.py index 533f4a1..52d2b59 100644 --- a/songryeon_core/runtime/terminal_view.py +++ b/songryeon_core/runtime/terminal_view.py @@ -1496,6 +1496,16 @@ def render_runtime_view(result: dict[str, object], *, user_input: str) -> str: f"budget={r_loop_material.get('budget_status', 'unknown')} / " f"hint={r_loop_material.get('attitude_hint', 'unknown')}" ) + vessel_r_material = node3_brief.get("vessel_r_material") + if isinstance(vessel_r_material, dict): + lines.append( + " - Vessel R material in brief: " + f"status={vessel_r_material.get('material_status', 'unknown')} / " + f"task={vessel_r_material.get('r_loop_task_status', 'unknown')} / " + f"items={len(vessel_r_material.get('material_items', []))} / " + f"summaries={vessel_r_material.get('summary_material_count', 0)} / " + f"raw_originals={vessel_r_material.get('raw_original_material_count', 0)}" + ) answer_basis_mode = node3_brief.get("answer_basis_mode") if isinstance(answer_basis_mode, str) and answer_basis_mode: lines.append( diff --git a/tests/test_order_193_r_result_to_node3_vessel_material.py b/tests/test_order_193_r_result_to_node3_vessel_material.py new file mode 100644 index 0000000..4d36334 --- /dev/null +++ b/tests/test_order_193_r_result_to_node3_vessel_material.py @@ -0,0 +1,212 @@ +import json + +from songryeon_core.core.data_store import DataStore +from songryeon_core.core.schemas import MetainfoBoundary +from songryeon_core.core.trace_store import TraceStore +from songryeon_core.llm.base import LLMRequest, LLMResponse +from songryeon_core.loops.r_loop_vessel_one_step import ( + RLoopVesselTraverseFakeLLMAdapter, + run_r_loop_vessel_traverse, +) +from songryeon_core.nodes.node_2_handoff import ( + node3_brief_llm_payload, + record_node3_input_brief, +) +from songryeon_core.nodes.node_3_reporter import build_node3_grounding_block +from songryeon_core.nodes.node_4_gatekeeper import run_node4_gatekeeper + +from tests.test_order_183_r_vessel_hierarchical_child_surface import ( + _source_ingest_row, + _time_axis_row, +) +from tests.test_order_184_r_vessel_multi_step_traversal import ( + SOURCE_KIND_ID, + SUMMARY_ID, + _record_packet, + _source_kind_row, + _summary_row, +) + + +def test_node3_brief_receives_vessel_r_summary_material_without_raw_id_payload() -> None: + trace_store, data_store, packet_event_id, packet = _record_packet( + entry_rows=[ + _time_axis_row(), + _source_ingest_row(source_graph_node_ids=[SOURCE_KIND_ID]), + _source_kind_row(source_graph_node_ids=[SUMMARY_ID]), + ], + summary_rows=[_summary_row()], + ) + traverse_result = run_r_loop_vessel_traverse( + trace_store=trace_store, + data_store=data_store, + turn_id="turn_order_193_traverse", + user_question="CoreEgo에서 소스 요약까지 계층적으로 내려가", + read_packet=packet, + adapter=RLoopVesselTraverseFakeLLMAdapter(), + frame_label="order_193_traverse", + input_ref=[packet_event_id], + ) + + _, _, brief = record_node3_input_brief( + trace_store=trace_store, + data_store=data_store, + turn_id="turn_order_193_brief", + user_question="R 탐색 결과를 최종 답변 재료로 써도 되는지 확인해줘", + handoff_frame_id="node_2:handoff:order_193", + boundary=MetainfoBoundary(), + input_trace_ids=[packet_event_id], + source_data_ids=["node_2:handoff:order_193"], + ) + + assert traverse_result.result_frame.r_loop_task_status == "sufficient" + assert brief.vessel_r_material_status == "present" + assert brief.vessel_r_material is not None + assert brief.vessel_r_material.r_loop_task_status == "sufficient" + assert brief.vessel_r_material.summary_material_count == 1 + assert brief.vessel_r_material_count == len(brief.vessel_r_material.material_items) + + summary_item = next( + item + for item in brief.vessel_r_material.material_items + if item.material_kind == "summary" + ) + assert summary_item.summary_text == "Code source kind bundle summary for R traversal." + assert summary_item.info_class == "mixed" + assert SUMMARY_ID in brief.vessel_r_material.source_data_ids + + payload = node3_brief_llm_payload(brief) + vessel_payload = payload["vessel_r_material"] + assert vessel_payload["status"] == "present" + assert vessel_payload["task_status"] == "sufficient" + assert vessel_payload["summary_material_count"] == 1 + assert "graph_node_id" not in vessel_payload["items"][0] + summary_payload_item = next( + item + for item in vessel_payload["items"] + if item["material_kind"] == "summary" + ) + assert summary_payload_item["summary_text"] == ( + "Code source kind bundle summary for R traversal." + ) + + grounding_block = build_node3_grounding_block(brief) + assert "Vessel R graph material: 4개" in grounding_block + + +def test_node3_brief_preserves_failed_vessel_r_material_as_failed() -> None: + trace_store = TraceStore() + data_store = DataStore() + data_store.create_record( + data_id="r_loop:vessel_traverse_result:order_193_failed", + data_type="r_loop:vessel_traverse_result", + payload={ + "frame_id": "r_loop:vessel_traverse_result:order_193_failed", + "source_packet_id": "r_loop:vessel_read_packet:missing", + "traverse_status": "failed", + "r_loop_task_status": "failed", + "failure_type": "schema_failed", + "failure_reason": "R2 selected_graph_node_id must be available", + "selected_graph_node_ids": [], + "inspected_graph_node_ids": [], + "source_data_ids": ["r_loop:vessel_read_packet:missing"], + "source_trace_ids": ["trace:r:failed"], + }, + source_trace_id="trace:r:failed", + ) + + _, _, brief = record_node3_input_brief( + trace_store=trace_store, + data_store=data_store, + turn_id="turn_order_193_failed", + user_question="R 실패 상태를 보존해줘", + handoff_frame_id="node_2:handoff:order_193_failed", + boundary=MetainfoBoundary(), + input_trace_ids=["trace:user"], + source_data_ids=["node_2:handoff:order_193_failed"], + ) + + assert brief.vessel_r_material_status == "failed" + assert brief.vessel_r_material_count == 0 + assert brief.vessel_r_material is not None + assert brief.vessel_r_material.failure_type == "schema_failed" + assert brief.vessel_r_material.material_items == [] + + +def test_node4_blocks_vessel_r_success_claim_when_material_is_failed() -> None: + trace_store = TraceStore() + data_store = DataStore() + data_store.create_record( + data_id="r_loop:vessel_traverse_result:order_193_guard_failed", + data_type="r_loop:vessel_traverse_result", + payload={ + "frame_id": "r_loop:vessel_traverse_result:order_193_guard_failed", + "source_packet_id": "r_loop:vessel_read_packet:missing", + "traverse_status": "failed", + "r_loop_task_status": "failed", + "failure_type": "schema_failed", + "failure_reason": "simulated failed traversal", + "selected_graph_node_ids": [], + "inspected_graph_node_ids": [], + "source_data_ids": ["r_loop:vessel_read_packet:missing"], + "source_trace_ids": ["trace:r:failed"], + }, + source_trace_id="trace:r:failed", + ) + _, _, brief = record_node3_input_brief( + trace_store=trace_store, + data_store=data_store, + turn_id="turn_order_193_guard", + user_question="R 실패 상태를 보존해줘", + handoff_frame_id="node_2:handoff:order_193_guard", + boundary=MetainfoBoundary(), + input_trace_ids=["trace:user"], + source_data_ids=["node_2:handoff:order_193_guard"], + ) + rendered_markdown = "\n\n".join( + [ + build_node3_grounding_block(brief), + "Vessel R 탐색은 성공했고 그래프 기억 탐색도 충분했다.", + ] + ) + + run_node4_gatekeeper( + trace_store=trace_store, + data_store=data_store, + turn_id="turn_order_193_guard", + report_id="node_3:report:order_193_guard", + boundary_id="node_2:boundary:order_193_guard", + brief_frame=brief, + rendered_markdown=rendered_markdown, + adapter=PassGatekeeperAdapter(), + input_ref=["trace:user"], + source_data_ids=[brief.frame_id], + ) + + gate_payload = data_store.require_record("node_4:gatekeeper_frame").payload + assert gate_payload["gate_status"] == "needs_revision" + assert "CODE_STATUS:vessel_r_material_claim_mismatch" in gate_payload["reason"] + assert any( + contradiction.startswith("vessel_r_success_claim_without_sufficient_material") + for contradiction in gate_payload["contradictions"] + ) + + +class PassGatekeeperAdapter: + model_id = "fake-pass-gatekeeper" + + def complete(self, request: LLMRequest) -> LLMResponse: + return LLMResponse( + text=json.dumps( + { + "gate_status": "pass", + "reason": "fake pass before code guards", + "checked_claims": ["fake_llm_pass"], + "unsupported_claims": [], + "contradictions": [], + "revision_targets": [], + }, + ensure_ascii=False, + ), + model_id=self.model_id, + )