From 18674af0bd91f8f88d25d51ce2a32f8eabe4791e Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:36:36 +0800 Subject: [PATCH 01/11] fix: resolve evidence from source quotes --- src/okf_compiler/schema.py | 271 ++++++++++++++++++++++++++++++++++--- 1 file changed, 255 insertions(+), 16 deletions(-) diff --git a/src/okf_compiler/schema.py b/src/okf_compiler/schema.py index 412a312..ba1c6c4 100644 --- a/src/okf_compiler/schema.py +++ b/src/okf_compiler/schema.py @@ -1,9 +1,13 @@ -"""OKF Bundle paths, identities, and data structures.""" +"""OKF Bundle paths, identities, data structures, and evidence resolution.""" from __future__ import annotations import json -from dataclasses import dataclass, field +import os +import re +from dataclasses import dataclass, field, replace +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version OKF_FORMAT = "okf-bundle" OKF_VERSION = 1 @@ -29,6 +33,7 @@ PROPOSED_EDGES_JSONL = "relations/proposed_edges.jsonl" ASSETS_DIR = "assets" IMAGES_DIR = "assets/images" +MEDIA_DIR = "assets/media" SOURCE_MAP_JSON = "source_map.json" SECTION_INDEX_WIDTH = 2 SECTION_TYPE = "Section" @@ -97,25 +102,51 @@ def to_manifest(self) -> dict: @dataclass -class ImageRef: +class AssetRef: original_ref: str dest_name: str source_path: str found: bool alt: str = "" + kind: str = "image" + bundle_dir: str = IMAGES_DIR + + @property + def bundle_ref(self) -> str | None: + return f"{self.bundle_dir}/{self.dest_name}" if self.found else None + + +ImageRef = AssetRef @dataclass class Evidence: - heading_path: str - line_start: int - line_end: int + heading_path: str = "" + line_start: int = 0 + line_end: int = 0 section_id: str = "" + quote: str = "" def is_valid(self) -> bool: return bool(self.heading_path) and self.line_start >= 1 and self.line_end >= self.line_start +@dataclass +class EvidenceValidation: + valid: bool + reason: str + evidence: Evidence | None = None + details: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "valid": self.valid, + "reason": self.reason, + "evidence": evidence_to_dict(self.evidence), + "details": self.details, + } + + @dataclass class ConceptExtract: name: str @@ -150,20 +181,220 @@ class Extracts: entities: list[EntityExtract] = field(default_factory=list) relations: list[ProposedEdge] = field(default_factory=list) warnings: list[str] = field(default_factory=list) + stage_stats: dict[str, dict] = field(default_factory=dict) + validation: list[dict] = field(default_factory=list) + @property + def degraded(self) -> bool: + return any( + str(stats.get("status", "")).lower() in {"degraded", "failed"} + for stats in self.stage_stats.values() + ) + + +def evidence_to_dict(evidence: Evidence | None) -> dict | None: + if evidence is None: + return None + return { + "section_id": evidence.section_id, + "heading_path": evidence.heading_path, + "line_start": evidence.line_start, + "line_end": evidence.line_end, + "quote": evidence.quote, + } -def validate_evidence(evidence: Evidence | None, sections: list[SectionSpec], total_lines: int) -> bool: - if evidence is None or not evidence.is_valid(): - return False + +def resolve_evidence( + evidence: Evidence | None, + sections: list[SectionSpec], + markdown: str, +) -> EvidenceValidation: + """Resolve quote-based evidence to deterministic absolute line coordinates.""" + if evidence is None: + return EvidenceValidation(False, "missing_evidence") + + section_result = _select_section(evidence, sections) + if isinstance(section_result, EvidenceValidation): + return section_result + section = section_result + + quote = evidence.quote.strip() + if quote: + return _locate_quote(section, markdown, quote) + + return validate_evidence_detailed(evidence, sections, max(len(markdown.splitlines()), 1)) + + +def validate_evidence_detailed( + evidence: Evidence | None, + sections: list[SectionSpec], + total_lines: int, +) -> EvidenceValidation: + if evidence is None: + return EvidenceValidation(False, "missing_evidence") + if not evidence.heading_path: + return EvidenceValidation(False, "missing_heading_path", evidence) + if evidence.line_start < 1: + return EvidenceValidation(False, "invalid_line_start", evidence) + if evidence.line_end < evidence.line_start: + return EvidenceValidation(False, "invalid_line_range", evidence) if evidence.line_end > total_lines: - return False - for sec in sections: - if evidence.section_id and sec.section_id != evidence.section_id: - continue - if not evidence.section_id and sec.heading_path != evidence.heading_path: + return EvidenceValidation( + False, + "line_end_out_of_document", + evidence, + {"total_lines": total_lines}, + ) + + section_result = _select_section(evidence, sections) + if isinstance(section_result, EvidenceValidation): + return section_result + section = section_result + if not (section.line_start <= evidence.line_start <= evidence.line_end <= section.line_end): + return EvidenceValidation( + False, + "line_range_outside_section", + evidence, + { + "section_id": section.section_id, + "section_line_start": section.line_start, + "section_line_end": section.line_end, + }, + ) + return EvidenceValidation(True, "ok_legacy_line_evidence", evidence) + + +def validate_evidence(evidence: Evidence | None, sections: list[SectionSpec], total_lines: int) -> bool: + return validate_evidence_detailed(evidence, sections, total_lines).valid + + +def _select_section( + evidence: Evidence, + sections: list[SectionSpec], +) -> SectionSpec | EvidenceValidation: + if not sections: + return EvidenceValidation(False, "no_sections", evidence) + if evidence.section_id: + matches = [sec for sec in sections if sec.section_id == evidence.section_id] + if not matches: + return EvidenceValidation(False, "unknown_section_id", evidence) + return matches[0] + if evidence.heading_path: + matches = [sec for sec in sections if sec.heading_path == evidence.heading_path] + if not matches: + return EvidenceValidation(False, "unknown_heading_path", evidence) + if len(matches) > 1: + return EvidenceValidation(False, "ambiguous_heading_path", evidence) + return matches[0] + return EvidenceValidation(False, "missing_section_locator", evidence) + + +def _locate_quote(section: SectionSpec, markdown: str, quote: str) -> EvidenceValidation: + lines = markdown.splitlines(keepends=True) + section_text = "".join(lines[max(section.line_start - 1, 0) : min(section.line_end, len(lines))]) + + exact_positions = _all_positions(section_text, quote) + if len(exact_positions) == 1: + start = exact_positions[0] + return _resolved_quote(section, section_text, quote, start, start + len(quote), "ok_exact_quote") + if len(exact_positions) > 1: + return EvidenceValidation( + False, + "ambiguous_quote", + replace(section_evidence(section), quote=quote), + {"matches": len(exact_positions), "match_mode": "exact"}, + ) + + normalized_text, mapping = _normalize_with_map(section_text) + normalized_quote = re.sub(r"\s+", " ", quote).strip() + normalized_positions = _all_positions(normalized_text, normalized_quote) if normalized_quote else [] + if len(normalized_positions) == 1: + norm_start = normalized_positions[0] + norm_end = norm_start + len(normalized_quote) - 1 + return _resolved_quote( + section, + section_text, + quote, + mapping[norm_start], + mapping[norm_end] + 1, + "ok_normalized_quote", + ) + if len(normalized_positions) > 1: + return EvidenceValidation( + False, + "ambiguous_quote", + replace(section_evidence(section), quote=quote), + {"matches": len(normalized_positions), "match_mode": "normalized_whitespace"}, + ) + return EvidenceValidation( + False, + "quote_not_found", + replace(section_evidence(section), quote=quote), + {"section_id": section.section_id}, + ) + + +def _resolved_quote( + section: SectionSpec, + section_text: str, + quote: str, + start: int, + end: int, + reason: str, +) -> EvidenceValidation: + last_char = max(start, end - 1) + line_start = section.line_start + section_text[:start].count("\n") + line_end = section.line_start + section_text[:last_char].count("\n") + resolved = Evidence( + section_id=section.section_id, + heading_path=section.heading_path, + line_start=line_start, + line_end=max(line_start, line_end), + quote=quote, + ) + return EvidenceValidation(True, reason, resolved) + + +def section_evidence(section: SectionSpec) -> Evidence: + return Evidence( + section_id=section.section_id, + heading_path=section.heading_path, + line_start=section.line_start, + line_end=section.line_end, + ) + + +def _all_positions(text: str, needle: str) -> list[int]: + if not needle: + return [] + positions: list[int] = [] + start = 0 + while True: + index = text.find(needle, start) + if index < 0: + return positions + positions.append(index) + start = index + 1 + + +def _normalize_with_map(text: str) -> tuple[str, list[int]]: + chars: list[str] = [] + mapping: list[int] = [] + in_space = False + for index, char in enumerate(text): + if char.isspace(): + if chars and not in_space: + chars.append(" ") + mapping.append(index) + in_space = True continue - return sec.line_start <= evidence.line_start <= evidence.line_end <= sec.line_end - return not sections and evidence.line_end <= total_lines + chars.append(char) + mapping.append(index) + in_space = False + while chars and chars[-1] == " ": + chars.pop() + mapping.pop() + return "".join(chars), mapping def okf_yaml_text(title: str) -> str: @@ -182,13 +413,21 @@ def okf_yaml_text(title: str) -> str: def manifest_compiler_block(*, llm_enabled: bool, model: str | None) -> dict: + try: + version = package_version("okf-compiler") + except PackageNotFoundError: + version = "0+unknown" out = { "name": COMPILER_NAME, + "version": version, "mode": COMPILER_MODE, "global_read": False, "global_write": False, "llm_enabled": bool(llm_enabled), } + git_commit = os.environ.get("OKF_COMPILER_GIT_COMMIT", "").strip() + if git_commit: + out["git_commit"] = git_commit if model: out["model"] = model return out From 8fe08ff7dd602bdee91837ef65cff1a55e123423 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:37:28 +0800 Subject: [PATCH 02/11] fix: bundle referenced HTML media assets --- src/okf_compiler/assets.py | 161 ++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 46 deletions(-) diff --git a/src/okf_compiler/assets.py b/src/okf_compiler/assets.py index ce12dbf..71dc3d5 100644 --- a/src/okf_compiler/assets.py +++ b/src/okf_compiler/assets.py @@ -1,4 +1,4 @@ -"""Collect relative Markdown images into a portable OKF bundle.""" +"""Collect relative Markdown and HTML media into a portable OKF bundle.""" from __future__ import annotations @@ -7,69 +7,137 @@ import shutil from pathlib import Path -from .schema import IMAGES_DIR, ImageRef - -_RELATIVE_RE = re.compile(r"!\[([^\]]*)\]\((?!https?://|data:)([^)]+)\)") -_REWRITTEN_PREFIX = f"../{IMAGES_DIR}/" - - -def collect_images( - section_bodies: list[str], source_markdown: str, source_dir: Path, images_dir: Path -) -> tuple[list[str], str, list[ImageRef], list[str]]: - """Copy referenced images and rewrite both section and normalized source links.""" +from .schema import IMAGES_DIR, MEDIA_DIR, AssetRef + +_MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((?!https?://|data:)([^)]+)\)", re.IGNORECASE) +_HTML_TAG_RE = re.compile(r"<(?Pvideo|source|audio|img)\b[^>]*>", re.IGNORECASE) +_HTML_ATTR_RE = re.compile( + r"(?P\b(?Psrc|poster)\s*=\s*)" + r"(?P[\"'])(?P[^\"']+)(?P=quote)", + re.IGNORECASE, +) +_IMAGE_SUFFIXES = {".apng", ".avif", ".bmp", ".gif", ".ico", ".jpg", ".jpeg", ".png", ".svg", ".webp"} +_AUDIO_SUFFIXES = {".aac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav"} +_VIDEO_SUFFIXES = {".avi", ".m4v", ".mkv", ".mov", ".mp4", ".webm"} + + +def collect_assets( + section_bodies: list[str], + source_markdown: str, + source_dir: Path, + workdir: Path, +) -> tuple[list[str], str, list[AssetRef], list[str]]: warnings: list[str] = [] - refs: list[ImageRef] = [] - assigned: dict[str, str] = {} - taken: set[str] = set() + refs: list[AssetRef] = [] + assigned: dict[tuple[str, str], str] = {} + taken: dict[str, set[str]] = {IMAGES_DIR: set(), MEDIA_DIR: set()} + source_dir = Path(source_dir).resolve() + workdir = Path(workdir) + + def register(rel_path: str, *, alt: str, kind: str, collect_ref: bool) -> str | None: + if _is_external_or_absolute(rel_path): + return None + norm = _normalize_rel(rel_path) + src = _resolve_under(rel_path, source_dir) + bundle_dir = IMAGES_DIR if kind == "image" else MEDIA_DIR + if src is None: + if collect_ref: + warnings.append(f"asset path escapes source dir: {rel_path}") + return None + if not src.is_file(): + if collect_ref: + warnings.append(f"relative asset not found: {rel_path}") + refs.append(AssetRef(rel_path, "", str(src), False, alt, kind, bundle_dir)) + return None + + key = (bundle_dir, norm) + dest_name = assigned.get(key) + if dest_name is None: + dest_name = _unique_name(src.name, norm, taken[bundle_dir]) + assigned[key] = dest_name + taken[bundle_dir].add(dest_name) + destination = workdir / bundle_dir / dest_name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, destination) + if collect_ref: + refs.append(AssetRef(rel_path, dest_name, str(src), True, alt, kind, bundle_dir)) + return f"../{bundle_dir}/{dest_name}" def rewrite(text: str, *, collect_refs: bool) -> str: - parts: list[str] = [] - cursor = 0 - for match in _RELATIVE_RE.finditer(text): - parts.append(text[cursor : match.start()]) + def markdown_repl(match: re.Match[str]) -> str: alt, rel_path = match.group(1), match.group(2) - norm = _normalize_rel(rel_path) - src = _resolve_under(rel_path, source_dir) - replacement = match.group(0) - if src is None: - if collect_refs: - warnings.append(f"image path escapes source dir: {rel_path}") - elif not src.is_file(): - if collect_refs: - warnings.append(f"relative image not found: {rel_path}") - refs.append(ImageRef(rel_path, "", str(src), False, alt)) - else: - dest_name = assigned.get(norm) - if dest_name is None: - dest_name = _unique_name(src.name, norm, taken) - assigned[norm] = dest_name - taken.add(dest_name) - images_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, images_dir / dest_name) - if collect_refs: - refs.append(ImageRef(rel_path, dest_name, str(src), True, alt)) - replacement = f"![{alt}]({_REWRITTEN_PREFIX}{dest_name})" - parts.append(replacement) - cursor = match.end() - parts.append(text[cursor:]) - return "".join(parts) + replacement = register(rel_path, alt=alt, kind="image", collect_ref=collect_refs) + return f"![{alt}]({replacement})" if replacement else match.group(0) + + text = _MARKDOWN_IMAGE_RE.sub(markdown_repl, text) + + def html_tag_repl(tag_match: re.Match[str]) -> str: + tag = tag_match.group("tag").lower() + + def html_attr_repl(attr_match: re.Match[str]) -> str: + rel_path = attr_match.group("path") + attr = attr_match.group("attr").lower() + kind = _asset_kind(tag, attr, rel_path) + replacement = register(rel_path, alt=attr, kind=kind, collect_ref=collect_refs) + if replacement is None: + return attr_match.group(0) + quote = attr_match.group("quote") + return f"{attr_match.group('prefix')}{quote}{replacement}{quote}" + + return _HTML_ATTR_RE.sub(html_attr_repl, tag_match.group(0)) + + return _HTML_TAG_RE.sub(html_tag_repl, text) rewritten_sections = [rewrite(body, collect_refs=True) for body in section_bodies] normalized_source = rewrite(source_markdown, collect_refs=False) return rewritten_sections, normalized_source, refs, warnings -def count_missing(refs: list[ImageRef]) -> int: +def collect_images( + section_bodies: list[str], source_markdown: str, source_dir: Path, images_dir: Path +) -> tuple[list[str], str, list[AssetRef], list[str]]: + images_dir = Path(images_dir) + try: + workdir = images_dir.parents[1] + except IndexError: + workdir = images_dir.parent + return collect_assets(section_bodies, source_markdown, source_dir, workdir) + + +def count_missing(refs: list[AssetRef]) -> int: return sum(1 for ref in refs if not ref.found) +def _asset_kind(tag: str, attr: str, rel_path: str) -> str: + suffix = Path(rel_path.split("?", 1)[0].split("#", 1)[0]).suffix.lower() + if tag == "img" or attr == "poster" or suffix in _IMAGE_SUFFIXES: + return "image" + if tag == "audio" or suffix in _AUDIO_SUFFIXES: + return "audio" + if tag == "video" or suffix in _VIDEO_SUFFIXES: + return "video" + return "media" + + +def _is_external_or_absolute(rel_path: str) -> bool: + value = rel_path.strip().strip('"').strip("'") + lower = value.lower() + return ( + not value + or lower.startswith(("http://", "https://", "data:", "//", "#")) + or value.startswith(("/", "\\")) + or bool(re.match(r"^[A-Za-z]:", value)) + ) + + def _resolve_under(rel_path: str, source_dir: Path) -> Path | None: value = rel_path.strip().strip('"').strip("'") - if not value or value.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", value): + if _is_external_or_absolute(value): return None + value = value.split("?", 1)[0].split("#", 1)[0] candidate = (source_dir / value).resolve() try: - candidate.relative_to(source_dir.resolve()) + candidate.relative_to(source_dir) except ValueError: return None return candidate @@ -77,6 +145,7 @@ def _resolve_under(rel_path: str, source_dir: Path) -> Path | None: def _normalize_rel(rel_path: str) -> str: value = rel_path.strip().strip('"').strip("'").replace("\\", "/") + value = value.split("?", 1)[0].split("#", 1)[0] return "/".join(part for part in value.split("/") if part and part != ".") From 8b97ddf519aae2496213f9805c895adc28f87d58 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:37:50 +0800 Subject: [PATCH 03/11] fix: standardize quote evidence prompts --- src/okf_compiler/prompts.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/okf_compiler/prompts.py b/src/okf_compiler/prompts.py index 2478117..47affe7 100644 --- a/src/okf_compiler/prompts.py +++ b/src/okf_compiler/prompts.py @@ -20,10 +20,19 @@ def _section_map(sections: list[SectionSpec]) -> str: return json.dumps(rows, ensure_ascii=False, indent=2) +def _evidence_contract() -> str: + return ( + 'Evidence MUST be an object exactly shaped as ' + '{"section_id":"s0001","quote":"verbatim contiguous text copied from Document"}. ' + "The quote must occur once inside that section, contain no ellipsis or paraphrase, and must not " + "include invented line numbers. The compiler resolves quote text to absolute source lines." + ) + + def summary_messages(markdown: str, sections: list[SectionSpec], language: str) -> tuple[str, str]: system = ( "You summarize one Markdown document in isolation. Do not use external knowledge. " - "Return JSON only: {\"summary\": \"...\"}." + 'Return JSON only: {"summary": "..."}.' ) user = f"Language: {language}\nSections:\n{_section_map(sections)}\n\nDocument:\n{markdown}" return system, user @@ -34,8 +43,7 @@ def concepts_messages( ) -> tuple[str, str]: system = ( "Extract document-local concepts. Return JSON only with a concepts array. Each item needs " - "name, description, confidence, and evidence containing section_id, heading_path, " - "line_start, line_end. Evidence must match the supplied section map." + "name, description, confidence, and evidence. " + _evidence_contract() ) user = ( f"Maximum concepts: {max_concepts}\nSummary: {summary}\n" @@ -54,7 +62,7 @@ def entities_messages( ) -> tuple[str, str]: system = ( "Extract document-local named entities. Return JSON only with an entities array. Each item " - "needs name, type, description, aliases, confidence, and valid section evidence." + "needs name, type, description, aliases, confidence, and evidence. " + _evidence_contract() ) concept_names = [c.name for c in concepts] user = ( @@ -74,7 +82,8 @@ def relations_messages( ) -> tuple[str, str]: system = ( "Propose relations between the supplied local concepts/entities only. Return JSON only with " - "a relations array. Each relation needs subject, relation, object, note, and valid evidence." + "a relations array. Each relation needs subject, relation, object, note, and evidence. " + + _evidence_contract() ) nodes = [c.name for c in concepts] + [e.name for e in entities] user = ( From b65d18393572eba15046e421c7d4b990eb9cc348 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:38:06 +0800 Subject: [PATCH 04/11] feat: add structured debug recorder --- src/okf_compiler/diagnostics.py | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/okf_compiler/diagnostics.py diff --git a/src/okf_compiler/diagnostics.py b/src/okf_compiler/diagnostics.py new file mode 100644 index 0000000..4b00588 --- /dev/null +++ b/src/okf_compiler/diagnostics.py @@ -0,0 +1,90 @@ +"""Structured, opt-in diagnostics written outside the OKF bundle.""" + +from __future__ import annotations + +import json +import threading +import traceback +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +from .atomic import atomic_write_json, atomic_write_text + + +class DebugRecorder: + def __init__(self, root: Path, *, include_llm_payloads: bool = False): + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + self.include_llm_payloads = include_llm_payloads + self._lock = threading.Lock() + self._events: list[dict] = [] + + def event(self, event: str, **data) -> None: + row = {"timestamp": _now(), "event": event, **data} + with self._lock: + self._events.append(row) + path = self.root / "compiler.jsonl" + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=False) + "\n") + + def stage_request(self, stage: str, system: str, user: str) -> None: + self.event( + "llm_request", + stage=stage, + system_chars=len(system), + user_chars=len(user), + payload_saved=self.include_llm_payloads, + ) + if self.include_llm_payloads: + base = self.root / "stages" / stage + atomic_write_json(base / "request.json", {"system": system, "user": user}) + + def stage_response(self, stage: str, raw: str, parsed: object | None = None) -> None: + self.event( + "llm_response", + stage=stage, + response_chars=len(raw), + payload_saved=self.include_llm_payloads, + ) + if self.include_llm_payloads: + base = self.root / "stages" / stage + atomic_write_text(base / "response.raw.txt", raw) + if parsed is not None: + atomic_write_json(base / "response.parsed.json", parsed) + + def validation(self, stage: str, label: str, item: object, result: object) -> None: + base = self.root / "validation" + base.mkdir(parents=True, exist_ok=True) + row = { + "timestamp": _now(), + "stage": stage, + "label": label, + "item": item, + "result": result, + } + with self._lock: + with (base / f"{stage}.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, ensure_ascii=False) + "\n") + + def traceback( + self, + exc: BaseException, + *, + sanitizer: Callable[[str], str] | None = None, + ) -> None: + rendered = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) + message = str(exc) + if sanitizer: + rendered = sanitizer(rendered) + message = sanitizer(message) + atomic_write_text(self.root / "traceback.log", rendered) + self.event("exception", exception_type=type(exc).__name__, message=message) + + def finish(self, data: dict) -> None: + payload = {"finished_at": _now(), **data, "events": self._events} + atomic_write_json(self.root / "run.json", payload) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() From 9189c06d92b8776bff81348140d79f3ffce1d51f Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:38:51 +0800 Subject: [PATCH 05/11] fix: resolve and diagnose LLM evidence --- src/okf_compiler/llm.py | 186 ++++++++++++++++++++++++++++++---------- 1 file changed, 141 insertions(+), 45 deletions(-) diff --git a/src/okf_compiler/llm.py b/src/okf_compiler/llm.py index 9fe1321..fb06573 100644 --- a/src/okf_compiler/llm.py +++ b/src/okf_compiler/llm.py @@ -3,14 +3,15 @@ from __future__ import annotations import json -import logging import os import re +import time from dataclasses import dataclass from pathlib import Path from dotenv import dotenv_values +from .diagnostics import DebugRecorder from .prompts import concepts_messages, entities_messages, relations_messages, summary_messages from .schema import ( ConceptExtract, @@ -19,11 +20,10 @@ Extracts, ProposedEdge, SectionSpec, - validate_evidence, + evidence_to_dict, + resolve_evidence, ) -logger = logging.getLogger(__name__) - ENV_BASE_URL = "OKF_LLM_BASE_URL" ENV_MODEL = "OKF_LLM_MODEL" ENV_API_KEY = "OKF_LLM_API_KEY" @@ -57,17 +57,6 @@ def find_dotenv_path( search_dirs: list[Path] | tuple[Path, ...] | None = None, env: dict[str, str] | None = None, ) -> Path | None: - """Resolve the one dotenv file used for a command. - - Resolution order: - 1. explicit ``path`` / ``--env-file``; - 2. ``OKF_ENV_FILE`` from the process environment; - 3. ``.env`` in the current working directory; - 4. ``.env`` in each caller-provided search directory. - - Only one file is loaded. Explicit/configured files must exist so a typo - cannot silently fall back to unrelated credentials. - """ current_env = dict(os.environ) if env is None else env explicit = path if explicit is None: @@ -213,15 +202,21 @@ def _evidence(obj: dict) -> Evidence | None: raw = obj.get("evidence") if not isinstance(raw, dict): return None - try: - return Evidence( - heading_path=str(raw.get("heading_path") or ""), - line_start=int(raw.get("line_start")), - line_end=int(raw.get("line_end")), - section_id=str(raw.get("section_id") or ""), - ) - except (TypeError, ValueError): - return None + + def integer(name: str) -> int: + value = raw.get(name) + try: + return int(value) if value not in (None, "") else 0 + except (TypeError, ValueError): + return 0 + + return Evidence( + heading_path=str(raw.get("heading_path") or ""), + line_start=integer("line_start"), + line_end=integer("line_end"), + section_id=str(raw.get("section_id") or ""), + quote=str(raw.get("quote") or ""), + ) def _items(value: dict | list, key: str) -> list[dict]: @@ -239,60 +234,118 @@ def extract( language: str, max_concepts: int, max_entities: int, + debug: DebugRecorder | None = None, ) -> Extracts: out = Extracts() - total_lines = max(len(markdown.splitlines()), 1) def warn(label: str, exc: Exception | str) -> None: out.warnings.append(redact_secrets(f"{label}: {exc}", client.api_key)) + def complete(stage: str, system: str, user: str) -> tuple[str, dict | list]: + if debug: + debug.stage_request(stage, system, user) + started = time.monotonic() + raw = client.json_completion(system, user) + value = _parse_json(raw) + if debug: + debug.stage_response(stage, raw, value) + debug.event( + "llm_stage_completed", + stage=stage, + duration_ms=round((time.monotonic() - started) * 1000, 2), + ) + return raw, value + try: system, user = summary_messages(markdown, sections, language) - value = _parse_json(client.json_completion(system, user)) + _, value = complete("summary", system, user) out.summary = str(value.get("summary", "")) if isinstance(value, dict) else "" + out.stage_stats["summary"] = {"status": "ok" if out.summary else "degraded"} except Exception as exc: # noqa: BLE001 warn("summary extraction failed", exc) + out.stage_stats["summary"] = { + "status": "failed", + "error": redact_secrets(str(exc), client.api_key), + } + if debug: + debug.event( + "llm_stage_failed", + stage="summary", + error=redact_secrets(str(exc), client.api_key), + ) try: system, user = concepts_messages(markdown, sections, max_concepts, summary=out.summary) - for item in _items(_parse_json(client.json_completion(system, user)), "concepts")[:max_concepts]: - evidence = _evidence(item) - if not validate_evidence(evidence, sections, total_lines): - warn("concept dropped", f"invalid evidence for {item.get('name')!r}") + _, value = complete("concepts", system, user) + items = _items(value, "concepts")[:max_concepts] + rejected = 0 + for item in items: + name = str(item.get("name") or "").strip() + result = resolve_evidence(_evidence(item), sections, markdown) + if debug: + debug.validation("concepts", name, item, result.to_dict()) + if not result.valid: + rejected += 1 + warn("concept dropped", f"{result.reason} for {name!r}") + out.validation.append({"stage": "concepts", "label": name, **result.to_dict()}) continue out.concepts.append( ConceptExtract( - name=str(item.get("name") or "").strip(), + name=name, description=str(item.get("description") or "").strip(), confidence=_float_or_none(item.get("confidence")), - evidence=evidence, + evidence=result.evidence, ) ) + out.stage_stats["concepts"] = _stage_stats(len(items), len(out.concepts), rejected) except Exception as exc: # noqa: BLE001 warn("concept extraction failed", exc) + out.stage_stats["concepts"] = _failed_stage(exc, client.api_key) + if debug: + debug.event( + "llm_stage_failed", + stage="concepts", + error=redact_secrets(str(exc), client.api_key), + ) try: system, user = entities_messages( markdown, sections, max_entities, summary=out.summary, concepts=out.concepts ) - for item in _items(_parse_json(client.json_completion(system, user)), "entities")[:max_entities]: - evidence = _evidence(item) - if not validate_evidence(evidence, sections, total_lines): - warn("entity dropped", f"invalid evidence for {item.get('name')!r}") + _, value = complete("entities", system, user) + items = _items(value, "entities")[:max_entities] + rejected = 0 + for item in items: + name = str(item.get("name") or "").strip() + result = resolve_evidence(_evidence(item), sections, markdown) + if debug: + debug.validation("entities", name, item, result.to_dict()) + if not result.valid: + rejected += 1 + warn("entity dropped", f"{result.reason} for {name!r}") + out.validation.append({"stage": "entities", "label": name, **result.to_dict()}) continue aliases = item.get("aliases") if isinstance(item.get("aliases"), list) else [] out.entities.append( EntityExtract( - name=str(item.get("name") or "").strip(), + name=name, entity_type=str(item.get("type") or item.get("entity_type") or "entity").strip(), description=str(item.get("description") or "").strip(), aliases=[str(x) for x in aliases], confidence=_float_or_none(item.get("confidence")), - evidence=evidence, + evidence=result.evidence, ) ) + out.stage_stats["entities"] = _stage_stats(len(items), len(out.entities), rejected) except Exception as exc: # noqa: BLE001 warn("entity extraction failed", exc) + out.stage_stats["entities"] = _failed_stage(exc, client.api_key) + if debug: + debug.event( + "llm_stage_failed", + stage="entities", + error=redact_secrets(str(exc), client.api_key), + ) allowed = {c.name for c in out.concepts} | {e.name for e in out.entities} try: @@ -303,29 +356,72 @@ def warn(label: str, exc: Exception | str) -> None: concepts=out.concepts, entities=out.entities, ) - for item in _items(_parse_json(client.json_completion(system, user)), "relations"): - evidence = _evidence(item) - subject, obj = str(item.get("subject") or ""), str(item.get("object") or "") + _, value = complete("relations", system, user) + items = _items(value, "relations") + rejected = 0 + for item in items: + subject = str(item.get("subject") or "") + obj = str(item.get("object") or "") + label = f"{subject!r} -> {obj!r}" if subject not in allowed or obj not in allowed: - warn("relation dropped", f"unknown node: {subject!r} -> {obj!r}") + rejected += 1 + reason = "unknown_node" + warn("relation dropped", f"{reason}: {label}") + validation = { + "valid": False, + "reason": reason, + "evidence": evidence_to_dict(_evidence(item)), + "details": {}, + } + out.validation.append({"stage": "relations", "label": label, **validation}) + if debug: + debug.validation("relations", label, item, validation) continue - if not validate_evidence(evidence, sections, total_lines): - warn("relation dropped", f"invalid evidence: {subject!r} -> {obj!r}") + result = resolve_evidence(_evidence(item), sections, markdown) + if debug: + debug.validation("relations", label, item, result.to_dict()) + if not result.valid: + rejected += 1 + warn("relation dropped", f"{result.reason}: {label}") + out.validation.append({"stage": "relations", "label": label, **result.to_dict()}) continue out.relations.append( ProposedEdge( subject=subject, relation=str(item.get("relation") or "related_to"), object=obj, - evidence=evidence, + evidence=result.evidence, note=str(item.get("note") or ""), ) ) + out.stage_stats["relations"] = _stage_stats(len(items), len(out.relations), rejected) except Exception as exc: # noqa: BLE001 warn("relation extraction failed", exc) + out.stage_stats["relations"] = _failed_stage(exc, client.api_key) + if debug: + debug.event( + "llm_stage_failed", + stage="relations", + error=redact_secrets(str(exc), client.api_key), + ) return out +def _stage_stats(returned: int, accepted: int, rejected: int) -> dict: + status = "degraded" if rejected else "ok" + return {"status": status, "returned": returned, "accepted": accepted, "rejected": rejected} + + +def _failed_stage(exc: Exception, api_key: str | None) -> dict: + return { + "status": "failed", + "returned": 0, + "accepted": 0, + "rejected": 0, + "error": redact_secrets(str(exc), api_key), + } + + def _float_or_none(value) -> float | None: try: return float(value) if value is not None else None From 54a7ca73150bf407a5106dda4ba6f7f12166e1b6 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:39:52 +0800 Subject: [PATCH 06/11] feat: expose media and extraction diagnostics in bundles --- src/okf_compiler/render.py | 88 +++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/src/okf_compiler/render.py b/src/okf_compiler/render.py index 7cd1ee8..4b67e5e 100644 --- a/src/okf_compiler/render.py +++ b/src/okf_compiler/render.py @@ -20,6 +20,7 @@ INDEX_MD, LOG_MD, MANIFEST_JSON, + MEDIA_DIR, OKF_BUNDLE_KIND, OKF_BUNDLE_TYPE, OKF_FORMAT, @@ -33,10 +34,11 @@ SOURCE_MAP_JSON, SOURCES_DIR, SUMMARY_MD, + AssetRef, Extracts, - ImageRef, SectioningResult, SectionSpec, + evidence_to_dict, manifest_compiler_block, okf_yaml_text, slugify, @@ -49,7 +51,7 @@ def render_bundle( original_markdown: str, normalized_markdown: str, sections: list[SectionSpec], - image_refs: list[ImageRef], + asset_refs: list[AssetRef], extracts: Extracts, original_filename: str, title: str, @@ -71,17 +73,21 @@ def render_bundle( _write_concepts(workdir, extracts) _write_entities(workdir, extracts) _write_relations(workdir, extracts) - atomic_write_json(workdir / SOURCE_MAP_JSON, _source_map(sections, image_refs)) + atomic_write_json(workdir / SOURCE_MAP_JSON, _source_map(sections, asset_refs)) atomic_write_text(workdir / INDEX_MD, _index_md(title, sections, extracts)) - atomic_write_text(workdir / LOG_MD, _log_md(title, llm_enabled, warnings + extracts.warnings)) + atomic_write_text( + workdir / LOG_MD, + _log_md(title, llm_enabled, warnings + extracts.warnings, extracts.stage_stats), + ) counts = { "sections": len(sections), "concepts": len(extracts.concepts), "entities": len(extracts.entities), "relations": len(extracts.relations), - "images": sum(ref.found for ref in image_refs), - "missing_assets": sum(not ref.found for ref in image_refs), + "images": sum(ref.found and ref.kind == "image" for ref in asset_refs), + "media": sum(ref.found and ref.kind != "image" for ref in asset_refs), + "missing_assets": sum(not ref.found for ref in asset_refs), } manifest = { "format": OKF_FORMAT, @@ -103,6 +109,10 @@ def render_bundle( "markdown_bytes": len(original_markdown.encode("utf-8")), }, "compiler": manifest_compiler_block(llm_enabled=llm_enabled, model=model), + "extraction": { + "status": "degraded" if extracts.degraded else "ok", + "stages": extracts.stage_stats, + }, "warnings": [redact_secrets(w) for w in warnings + extracts.warnings], } atomic_write_json(workdir / MANIFEST_JSON, manifest) @@ -120,6 +130,7 @@ def _ensure_dirs(workdir: Path) -> None: RELATIONS_DIR, ASSETS_DIR, IMAGES_DIR, + MEDIA_DIR, ): (workdir / path).mkdir(parents=True, exist_ok=True) @@ -137,6 +148,16 @@ def _write_section(workdir: Path, section: SectionSpec) -> None: atomic_write_text(workdir / section.filename, frontmatter.block(lines) + section.body + "\n") +def _evidence_lines(evidence) -> list[str]: + return [ + frontmatter.kv_line("section_id", evidence.section_id if evidence else ""), + frontmatter.kv_line("heading_path", evidence.heading_path if evidence else ""), + f"line_start: {evidence.line_start if evidence else 0}", + f"line_end: {evidence.line_end if evidence else 0}", + frontmatter.kv_line("evidence_quote", evidence.quote if evidence else ""), + ] + + def _write_summary(workdir: Path, extracts: Extracts, title: str) -> None: body = extracts.summary.strip() or "_(no summary available)_" fm = frontmatter.block( @@ -151,16 +172,12 @@ def _write_summary(workdir: Path, extracts: Extracts, title: str) -> None: def _write_concepts(workdir: Path, extracts: Extracts) -> None: for concept in extracts.concepts: - evidence = concept.evidence lines = [ frontmatter.kv_line("type", "Local Concept"), frontmatter.kv_line("title", concept.name), frontmatter.kv_line("source", ARTICLE_MD), frontmatter.kv_line("scope", "local"), - frontmatter.kv_line("section_id", evidence.section_id if evidence else ""), - frontmatter.kv_line("heading_path", evidence.heading_path if evidence else ""), - f"line_start: {evidence.line_start if evidence else 0}", - f"line_end: {evidence.line_end if evidence else 0}", + *_evidence_lines(concept.evidence), ] if concept.confidence is not None: lines.append(f"confidence: {concept.confidence}") @@ -170,17 +187,13 @@ def _write_concepts(workdir: Path, extracts: Extracts) -> None: def _write_entities(workdir: Path, extracts: Extracts) -> None: for entity in extracts.entities: - evidence = entity.evidence lines = [ frontmatter.kv_line("type", f"Local {entity.entity_type.title()}"), frontmatter.kv_line("title", entity.name), frontmatter.kv_line("source", ARTICLE_MD), frontmatter.kv_line("scope", "local"), frontmatter.list_line("aliases", entity.aliases), - frontmatter.kv_line("section_id", evidence.section_id if evidence else ""), - frontmatter.kv_line("heading_path", evidence.heading_path if evidence else ""), - f"line_start: {evidence.line_start if evidence else 0}", - f"line_end: {evidence.line_end if evidence else 0}", + *_evidence_lines(entity.evidence), ] if entity.confidence is not None: lines.append(f"confidence: {entity.confidence}") @@ -191,7 +204,6 @@ def _write_entities(workdir: Path, extracts: Extracts) -> None: def _write_relations(workdir: Path, extracts: Extracts) -> None: lines = [] for relation in extracts.relations: - evidence = relation.evidence lines.append( json.dumps( { @@ -199,12 +211,7 @@ def _write_relations(workdir: Path, extracts: Extracts) -> None: "relation": relation.relation, "object": relation.object, "note": relation.note, - "evidence": { - "section_id": evidence.section_id if evidence else "", - "heading_path": evidence.heading_path if evidence else "", - "line_start": evidence.line_start if evidence else 0, - "line_end": evidence.line_end if evidence else 0, - }, + "evidence": evidence_to_dict(relation.evidence), }, ensure_ascii=False, ) @@ -236,14 +243,34 @@ def _index_md(title: str, sections: list[SectionSpec], extracts: Extracts) -> st return "\n".join(lines).rstrip() + "\n" -def _log_md(title: str, llm_enabled: bool, warnings: list[str]) -> str: +def _log_md(title: str, llm_enabled: bool, warnings: list[str], stage_stats: dict[str, dict]) -> str: lines = [f"# Compilation Log: {title}", "", f"- LLM enabled: {str(llm_enabled).lower()}"] + if stage_stats: + lines += ["", "## Extraction", ""] + for stage, stats in stage_stats.items(): + counts = "" + if "returned" in stats: + counts = ( + f", returned={stats.get('returned', 0)}, accepted={stats.get('accepted', 0)}, " + f"rejected={stats.get('rejected', 0)}" + ) + lines.append(f"- {stage}: {stats.get('status', 'unknown')}{counts}") if warnings: lines += ["", "## Warnings", ""] + [f"- {redact_secrets(w)}" for w in warnings] return "\n".join(lines).rstrip() + "\n" -def _source_map(sections: list[SectionSpec], image_refs: list[ImageRef]) -> dict: +def _source_map(sections: list[SectionSpec], asset_refs: list[AssetRef]) -> dict: + assets = [ + { + "kind": ref.kind, + "original_ref": ref.original_ref, + "bundle_ref": ref.bundle_ref, + "found": ref.found, + "alt": ref.alt, + } + for ref in asset_refs + ] return { "sections": [ { @@ -256,13 +283,6 @@ def _source_map(sections: list[SectionSpec], image_refs: list[ImageRef]) -> dict } for s in sections ], - "images": [ - { - "original_ref": ref.original_ref, - "bundle_ref": f"{IMAGES_DIR}/{ref.dest_name}" if ref.found else None, - "found": ref.found, - "alt": ref.alt, - } - for ref in image_refs - ], + "assets": assets, + "images": [item for item in assets if item["kind"] == "image"], } From 4a3de0729ccafb659987f32bd475b943d49dfe44 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:40:29 +0800 Subject: [PATCH 07/11] feat: add degraded status and debug sidecars --- src/okf_compiler/compiler.py | 152 +++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 43 deletions(-) diff --git a/src/okf_compiler/compiler.py b/src/okf_compiler/compiler.py index 3b83e0a..700e68f 100644 --- a/src/okf_compiler/compiler.py +++ b/src/okf_compiler/compiler.py @@ -2,22 +2,25 @@ from __future__ import annotations +import hashlib import json import shutil import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field +from datetime import datetime, timezone from pathlib import Path from typing import Callable -from .assets import collect_images, count_missing +from .assets import collect_assets, count_missing from .atomic import atomic_write_json from .bundle import write_zip +from .diagnostics import DebugRecorder from .frontmatter import parse as parse_frontmatter from .llm import LLMClient, LLMConfig, extract, redact_secrets from .markdown import extract_title, split_sections from .render import render_bundle -from .schema import Extracts +from .schema import Extracts, slugify OKF_ZIP_SUFFIX = ".okf.zip" DEFAULT_REPORT_NAME = "batch_report.json" @@ -34,6 +37,9 @@ class CompileOptions: max_concepts: int = 12 max_entities: int = 12 llm_config: LLMConfig | None = None + debug_dir: Path | None = None + debug_llm_payloads: bool = False + strict: bool = False @dataclass @@ -46,6 +52,13 @@ class CompileResult: manifest: dict | None = None warnings: list[str] = field(default_factory=list) workdir_path: Path | None = None + debug_dir: Path | None = None + degraded: bool = False + strict_failed: bool = False + + @property + def successful(self) -> bool: + return self.ok and not self.strict_failed def to_report( self, @@ -53,13 +66,23 @@ def to_report( input_root: Path | None = None, output_root: Path | None = None, ) -> dict: + if self.skipped: + status = "skipped" + elif not self.ok or self.strict_failed: + status = "failed" + elif self.degraded: + status = "degraded" + else: + status = "ok" return { "input": _relative(self.input_path, input_root), "output": _relative(self.output_path, output_root) if self.output_path else None, - "status": "skipped" if self.skipped else ("ok" if self.ok else "failed"), + "status": status, "error": self.error or None, "counts": (self.manifest or {}).get("counts"), + "extraction": (self.manifest or {}).get("extraction"), "warnings": self.warnings, + "debug_dir": str(self.debug_dir) if self.debug_dir else None, } @@ -67,6 +90,7 @@ def to_report( class BatchReport: total: int = 0 ok: int = 0 + degraded: int = 0 skipped: int = 0 failed: int = 0 results: list[CompileResult] = field(default_factory=list) @@ -77,6 +101,7 @@ def to_dict(self) -> dict: return { "total": self.total, "ok": self.ok, + "degraded": self.degraded, "skipped": self.skipped, "failed": self.failed, "results": [ @@ -100,23 +125,45 @@ def compile_one(input_md: Path, out: Path, opts: CompileOptions) -> CompileResul return CompileResult(input_md, out, False, skipped=True, error="output exists") workdir = _create_workdir(opts) + debug_path = _create_debug_dir(opts.debug_dir, input_md) if opts.debug_dir else None + debug = ( + DebugRecorder(debug_path, include_llm_payloads=opts.debug_llm_payloads) + if debug_path + else None + ) warnings: list[str] = [] + if debug: + debug.event( + "compile_started", + input=str(input_md), + output=str(out), + workdir=str(workdir), + no_llm=opts.no_llm, + strict=opts.strict, + ) try: markdown = input_md.read_text(encoding="utf-8") source_metadata = parse_frontmatter(markdown) sectioning = split_sections(markdown) sections = sectioning.sections title = extract_title(markdown) or str(source_metadata.get("title") or input_md.stem) - images_dir = workdir / "assets" / "images" - rewritten, normalized_source, image_refs, asset_warnings = collect_images( + rewritten, normalized_source, asset_refs, asset_warnings = collect_assets( [section.body for section in sections], markdown, input_md.parent, - images_dir, + workdir, ) warnings.extend(asset_warnings) for section, body in zip(sections, rewritten): section.body = body + if debug: + debug.event( + "source_prepared", + markdown_lines=max(len(markdown.splitlines()), 1), + sections=len(sections), + assets_found=sum(ref.found for ref in asset_refs), + assets_missing=count_missing(asset_refs), + ) extracts = Extracts() llm_enabled = False @@ -132,22 +179,24 @@ def compile_one(input_md: Path, out: Path, opts: CompileOptions) -> CompileResul language=opts.language, max_concepts=opts.max_concepts, max_entities=opts.max_entities, + debug=debug, ) llm_enabled = True except Exception as exc: # noqa: BLE001 - warnings.append( - redact_secrets(f"llm disabled: {type(exc).__name__}: {exc}") - ) - missing = count_missing(image_refs) + warnings.append(redact_secrets(f"llm disabled: {type(exc).__name__}: {exc}")) + if debug: + api_key = opts.llm_config.api_key if opts.llm_config else None + debug.traceback(exc, sanitizer=lambda text: redact_secrets(text, api_key)) + missing = count_missing(asset_refs) if missing: - warnings.append(f"assets: {missing} referenced image(s) not found") + warnings.append(f"assets: {missing} referenced local asset(s) not found") manifest = render_bundle( workdir, original_markdown=markdown, normalized_markdown=normalized_source, sections=sections, - image_refs=image_refs, + asset_refs=asset_refs, extracts=extracts, original_filename=input_md.name, title=title, @@ -159,22 +208,39 @@ def compile_one(input_md: Path, out: Path, opts: CompileOptions) -> CompileResul source_metadata=_source_metadata(source_metadata), ) write_zip(workdir, out) - return CompileResult( + degraded = extracts.degraded + strict_failed = bool(opts.strict and degraded) + error = "strict mode rejected degraded extraction" if strict_failed else "" + result = CompileResult( input_md, out, True, + error=error, manifest=manifest, warnings=warnings + extracts.warnings, workdir_path=workdir if opts.keep_workdir else None, + debug_dir=debug_path, + degraded=degraded, + strict_failed=strict_failed, ) + if debug: + debug.finish(result.to_report()) + return result except Exception as exc: # noqa: BLE001 - return CompileResult( + error = redact_secrets(f"{type(exc).__name__}: {exc}") + result = CompileResult( input_md, out, False, - error=redact_secrets(f"{type(exc).__name__}: {exc}"), + error=error, workdir_path=workdir if opts.keep_workdir else None, + debug_dir=debug_path, ) + if debug: + api_key = opts.llm_config.api_key if opts.llm_config else None + debug.traceback(exc, sanitizer=lambda text: redact_secrets(text, api_key)) + debug.finish(result.to_report()) + return result finally: if not opts.keep_workdir: shutil.rmtree(workdir, ignore_errors=True) @@ -213,12 +279,14 @@ def compile_dir( def record(result: CompileResult) -> None: nonlocal completed report.results.append(result) - if result.ok: - report.ok += 1 - elif result.skipped: + if result.skipped: report.skipped += 1 - else: + elif not result.successful: report.failed += 1 + elif result.degraded: + report.degraded += 1 + else: + report.ok += 1 completed += 1 if progress_callback: progress_callback(result, completed, report.total) @@ -260,7 +328,7 @@ def run(item: tuple[Path, Path]) -> CompileResult: for item in pending: result = run(item) record(result) - if fail_fast and not result.ok and not result.skipped: + if fail_fast and not result.successful and not result.skipped: break else: with ThreadPoolExecutor(max_workers=max_workers) as pool: @@ -273,10 +341,10 @@ def run(item: tuple[Path, Path]) -> CompileResult: futures[future][0], None, False, - error=str(exc), + error=redact_secrets(f"{type(exc).__name__}: {exc}"), ) record(result) - if fail_fast and not result.ok and not result.skipped: + if fail_fast and not result.successful and not result.skipped: for other in futures: other.cancel() break @@ -358,12 +426,7 @@ def _flat_inputs(input_dir: Path, glob_pattern: str, recursive: bool) -> list[Pa if not path.is_file() or path.suffix.lower() not in _MD_SUFFIXES: continue rel = path.relative_to(input_dir) - excluded = { - ".git", - ".venv", - "node_modules", - "__pycache__", - } + excluded = {".git", ".venv", "node_modules", "__pycache__"} if any(part in excluded or part.endswith(".okf") for part in rel.parts[:-1]): continue if glob_pattern not in {"*.md", "*"} and not path.match(glob_pattern): @@ -377,11 +440,7 @@ def _output_name(md: Path, used: set[str], *, relative_to: Path) -> str: if base not in used: return base parent = md.relative_to(relative_to).parent.name - candidate = ( - f"{parent}__{base}" - if parent - else f"{md.stem}_{len(used)}{OKF_ZIP_SUFFIX}" - ) + candidate = f"{parent}__{base}" if parent else f"{md.stem}_{len(used)}{OKF_ZIP_SUFFIX}" n = 1 while candidate in used: candidate = f"{parent}__{md.stem}_{n}{OKF_ZIP_SUFFIX}" @@ -392,12 +451,23 @@ def _output_name(md: Path, used: set[str], *, relative_to: Path) -> str: def _create_workdir(opts: CompileOptions) -> Path: if opts.workdir: Path(opts.workdir).mkdir(parents=True, exist_ok=True) - return Path( - tempfile.mkdtemp(prefix="okf-compile-", dir=opts.workdir) - ).resolve() + return Path(tempfile.mkdtemp(prefix="okf-compile-", dir=opts.workdir)).resolve() return Path(tempfile.mkdtemp(prefix="okf-compile-")).resolve() +def _create_debug_dir(root: Path | None, input_md: Path) -> Path | None: + if root is None: + return None + root = Path(root).resolve() + root.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + digest = hashlib.sha1(str(input_md).encode()).hexdigest()[:8] + name = slugify(input_md.stem) or "document" + path = root / f"{stamp}_{name[:48]}_{digest}" + path.mkdir(parents=True, exist_ok=False) + return path + + def _source_metadata(metadata: dict) -> dict: mapping = { "url": metadata.get("source_url") or metadata.get("url"), @@ -405,11 +475,7 @@ def _source_metadata(metadata: dict) -> dict: "published_at": metadata.get("published_at") or metadata.get("publish_time"), "converter": metadata.get("converted_by"), } - return { - key: value - for key, value in mapping.items() - if value not in (None, "") - } + return {key: value for key, value in mapping.items() if value not in (None, "")} def _relative(path: Path | None, root: Path | None) -> str | None: @@ -418,6 +484,6 @@ def _relative(path: Path | None, root: Path | None) -> str | None: try: if root: return path.resolve().relative_to(root.resolve()).as_posix() - return path.as_posix() except ValueError: - return path.as_posix() + pass + return str(path) From d2f8d9ad70b0b209822267a017aa141f0cf9e70b Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:41:01 +0800 Subject: [PATCH 08/11] feat: expose debug and strict CLI modes --- src/okf_compiler/cli.py | 79 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/src/okf_compiler/cli.py b/src/okf_compiler/cli.py index 1ef1e67..a342585 100644 --- a/src/okf_compiler/cli.py +++ b/src/okf_compiler/cli.py @@ -28,6 +28,29 @@ def _llm_options(fn): return fn +def _diagnostic_options(fn): + for decorator in ( + click.option( + "--strict", + is_flag=True, + help="Return a non-zero exit code when extraction is degraded.", + ), + click.option( + "--debug-llm-payloads", + is_flag=True, + help="Save full LLM prompts and responses under --debug-dir.", + ), + click.option( + "--debug-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Write structured diagnostics outside the OKF bundle.", + ), + ): + fn = decorator(fn) + return fn + + def _resolve_llm_config( *, base_url: str | None, @@ -46,6 +69,11 @@ def _resolve_llm_config( ) +def _validate_debug_options(debug_dir: Path | None, debug_llm_payloads: bool) -> None: + if debug_llm_payloads and debug_dir is None: + raise click.UsageError("--debug-llm-payloads requires --debug-dir") + + @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @click.version_option(package_name="okf-compiler") def main() -> None: @@ -62,6 +90,7 @@ def main() -> None: @click.option("--language", default="zh", show_default=True) @click.option("--max-concepts", type=int, default=12, show_default=True) @click.option("--max-entities", type=int, default=12, show_default=True) +@_diagnostic_options @_llm_options def compile_cmd( input_md: Path, @@ -73,6 +102,9 @@ def compile_cmd( language: str, max_concepts: int, max_entities: int, + debug_dir: Path | None, + debug_llm_payloads: bool, + strict: bool, base_url: str | None, model: str | None, api_key: str | None, @@ -80,6 +112,7 @@ def compile_cmd( env_file: Path | None, ) -> None: """Compile one Markdown file into one .okf.zip.""" + _validate_debug_options(debug_dir, debug_llm_payloads) target = out or input_md.with_name(input_md.stem + ".okf.zip") config = None if not no_llm: @@ -103,10 +136,13 @@ def compile_cmd( max_concepts=max_concepts, max_entities=max_entities, llm_config=config, + debug_dir=debug_dir, + debug_llm_payloads=debug_llm_payloads, + strict=strict, ), ) _print_result(result) - if not result.ok: + if not result.successful: raise click.exceptions.Exit(1) @@ -137,6 +173,7 @@ def compile_cmd( @click.option("--language", default="zh", show_default=True) @click.option("--max-concepts", type=int, default=12, show_default=True) @click.option("--max-entities", type=int, default=12, show_default=True) +@_diagnostic_options @_llm_options def compile_dir_cmd( input_dir: Path, @@ -153,6 +190,9 @@ def compile_dir_cmd( language: str, max_concepts: int, max_entities: int, + debug_dir: Path | None, + debug_llm_payloads: bool, + strict: bool, base_url: str | None, model: str | None, api_key: str | None, @@ -160,6 +200,7 @@ def compile_dir_cmd( env_file: Path | None, ) -> None: """Compile a directory, producing one OKF Bundle per Markdown article.""" + _validate_debug_options(debug_dir, debug_llm_payloads) config = None if not no_llm: config = _resolve_llm_config( @@ -172,7 +213,14 @@ def compile_dir_cmd( ) def progress(result, completed: int, total: int) -> None: - status = "OK" if result.ok else ("SKIP" if result.skipped else "FAIL") + if result.skipped: + status = "SKIP" + elif not result.successful: + status = "FAIL" + elif result.degraded: + status = "DEGRADED" + else: + status = "OK" click.echo(f"[{completed}/{total}] {status} {result.input_path.name}") report = compile_dir( @@ -185,6 +233,9 @@ def progress(result, completed: int, total: int) -> None: max_concepts=max_concepts, max_entities=max_entities, llm_config=config, + debug_dir=debug_dir, + debug_llm_payloads=debug_llm_payloads, + strict=strict, ), mode=mode, glob_pattern=glob_pattern, @@ -196,7 +247,7 @@ def progress(result, completed: int, total: int) -> None: progress_callback=progress, ) click.echo( - f"Completed: {report.ok} ok, {report.skipped} skipped, " + f"Completed: {report.ok} ok, {report.degraded} degraded, {report.skipped} skipped, " f"{report.failed} failed, {report.total} total" ) if report.failed: @@ -230,16 +281,30 @@ def test_llm_cmd( def _print_result(result) -> None: if result.ok: - click.echo(f"OKF bundle written: {result.output_path}") + suffix = " with degraded extraction" if result.degraded else "" + click.echo(f"OKF bundle written{suffix}: {result.output_path}") counts = (result.manifest or {}).get("counts", {}) - click.echo( - "Counts: " + ", ".join(f"{key}={value}" for key, value in counts.items()) - ) + click.echo("Counts: " + ", ".join(f"{key}={value}" for key, value in counts.items())) + stages = ((result.manifest or {}).get("extraction") or {}).get("stages", {}) + for stage, stats in stages.items(): + if "returned" in stats: + click.echo( + f"Extraction {stage}: {stats.get('accepted', 0)}/{stats.get('returned', 0)} " + f"accepted ({stats.get('status', 'unknown')})" + ) + else: + click.echo(f"Extraction {stage}: {stats.get('status', 'unknown')}") for warning in result.warnings: click.echo(f"Warning: {warning}", err=True) + if result.debug_dir: + click.echo(f"Debug diagnostics: {result.debug_dir}") + if result.strict_failed: + click.echo(f"Strict failure: {result.error}", err=True) else: state = "skipped" if result.skipped else "failed" click.echo(f"Compile {state}: {result.error}", err=True) + if result.debug_dir: + click.echo(f"Debug diagnostics: {result.debug_dir}", err=True) if __name__ == "__main__": From a56a9bb9defeefa081eec3e5f240c70fe832bd89 Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:41:10 +0800 Subject: [PATCH 09/11] chore: bump version for evidence and diagnostics changes --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fca22f9..0f4be37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "okf-compiler" -version = "0.1.1" +version = "0.2.0" description = "Compile Markdown articles into isolated, self-contained OKF Bundles." readme = "README.md" requires-python = ">=3.10" From 7256a66ab6f2e2d34cf69945674edbb416ff75fd Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:41:31 +0800 Subject: [PATCH 10/11] docs: describe debug evidence and media support --- README.md | 104 +++++++++++++++++++++++++++++------------------------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 437c20b..f3eb6b8 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Compile one Markdown article into one isolated, self-contained **OKF Bundle** (` OKF Compiler follows a subtraction-first rule: every document starts fresh. A compile reads only the current Markdown file and its relative assets. It never reads or mutates a long-lived knowledge base, performs no global concept merge, and materializes no global backlinks. -The project was extracted from the experimental `openkb/okf` subsystem in `FFFFFFpy/OpenKB` and made independent of the OpenKB runtime. - ## What it produces ```text @@ -20,7 +18,8 @@ article.okf.zip │ └── article.md ├── sections/ ├── assets/ -│ └── images/ +│ ├── images/ +│ └── media/ ├── extracts/ │ ├── summary.md │ ├── concepts/ @@ -31,7 +30,8 @@ article.okf.zip ``` - `sources/original.md` preserves the input verbatim. -- `sources/article.md` is the portable source with image links rewritten to bundled assets. +- `sources/article.md` is the portable source with local asset links rewritten into the bundle. +- Referenced Markdown images and local HTML `img`, `video`, `audio`, and `source` assets are copied. - `sections/*.md` are split conservatively from real ATX headings. - LLM output is document-local and evidence-anchored. It is proposed knowledge, not a global wiki mutation. @@ -59,8 +59,6 @@ uv sync --extra dev --extra llm ## Configure the LLM with `.env` -Copy the included template and fill in your provider values: - ```powershell Copy-Item .env.example .env ``` @@ -72,72 +70,90 @@ OKF_LLM_API_KEY=your-key OKF_LLM_TIMEOUT=120 ``` -Then test the connection: - -```powershell -uv run okf test-llm -``` - Configuration priority is: ```text CLI flags > process environment > .env > defaults ``` -Dotenv discovery order is: +Dotenv discovery order is `--env-file`, `OKF_ENV_FILE`, `.env` in the current working directory, then `.env` beside the input. -1. `--env-file PATH`. -2. The path in `OKF_ENV_FILE`. -3. `.env` in the current working directory. -4. `.env` beside the input Markdown or input directory. +## Compile one Markdown file -Only one dotenv file is loaded. An explicitly selected file must exist; a typo does not silently fall back to some unrelated `.env` lurking elsewhere like a credential poltergeist. +```powershell +uv run okf compile article.md +``` -Use a custom file when needed: +Without LLM: ```powershell -uv run okf test-llm --env-file "D:\secrets\okf.env" +uv run okf compile article.md --no-llm ``` -`.env` is ignored by Git. `.env.example` is safe to commit and contains no real credentials. +By default the output is `article.okf.zip` beside the source file. -For migration, the old `OPENKB_LLM_*` names remain accepted as fallback values. +## Debug diagnostics -## Compile one Markdown file - -Without LLM: +Normal bundle logs stay compact. For structured sidecar diagnostics: ```powershell -uv run okf compile article.md --no-llm +uv run okf compile article.md ` + --debug-dir ".\okf-debug" ``` -With `.env` configured, no repeated LLM flags are needed: +The run directory contains: -```powershell -uv run okf compile article.md +```text +okf-debug// +├── run.json +├── compiler.jsonl +├── traceback.log # only when an exception occurs +└── validation/ + ├── concepts.jsonl + ├── entities.jsonl + └── relations.jsonl ``` -CLI flags remain available for one-off overrides: +To additionally save full LLM prompts, raw responses, and parsed JSON: ```powershell uv run okf compile article.md ` - --base-url "https://api.example.com/v1" ` - --model "your-model" ` - --api-key "your-key" + --debug-dir ".\okf-debug" ` + --debug-llm-payloads ``` -By default the output is `article.okf.zip` beside the source file. +Full payload logs may contain the complete source article. API keys are not intentionally written, but debug directories should still be treated as private data. -## Compile a wxarticle2md output directory +Use strict mode when degraded extraction must fail CI or a batch job: + +```powershell +uv run okf compile article.md --strict +``` + +The bundle is still written for inspection, but the command returns a non-zero exit code when an extraction stage rejected output. + +## Evidence model -`wxarticle2md --md-mode advanced-lite` produces one article directory containing one Markdown file and `assets/`. OKF Compiler can enumerate that layout directly: +New LLM extraction uses quote-based evidence: + +```json +{ + "section_id": "s0002", + "quote": "verbatim contiguous text copied from the document" +} +``` + +The compiler locates the quote inside the selected section and calculates absolute `line_start` and `line_end`. This avoids asking an LLM to count Markdown lines. Legacy line-based evidence remains accepted for compatibility. + +## Compile a wxarticle2md output directory ```powershell uv run okf compile-dir ` "D:\MyProjects\wxarticle2md\output" ` --out "D:\MyProjects\okf-bundles" ` --mode wechat ` - --max-workers 3 + --max-workers 3 ` + --debug-dir "D:\MyProjects\okf-debug" ``` Input selection priority in `wechat` mode: @@ -155,8 +171,6 @@ Input selection priority in `wechat` mode: - Lower headings and conservative pseudo-heading matches become section anchors, not extra top-level sections. - If no suitable heading level exists, the document becomes one whole-document section. -This deliberately favors stable, coarse sections over turning every bold sentence into a tiny knowledge fragment. Software has enough confetti already. - ## Python API ```python @@ -168,21 +182,13 @@ result = compile_one( Path("article.okf.zip"), CompileOptions(no_llm=True), ) - -if not result.ok: +if not result.successful: raise RuntimeError(result.error) ``` ## Design boundaries -OKF Compiler is a producer, not a knowledge-base runtime. Consumers such as OpenViking, WeKnora, or OpenKB may: - -- index sections, summaries, concepts, entities, and images separately; -- resolve local concepts against global candidates; -- accept or reject proposed edges; -- materialize wiki pages or backlinks later. - -The compiler itself does none of those global operations. +OKF Compiler is a producer, not a knowledge-base runtime. Consumers such as OpenViking, WeKnora, or OpenKB may index sections and extracts, resolve local concepts against global candidates, and accept or reject proposed edges. The compiler itself performs no global mutation. ## License and provenance From 259eec687272b547ccb72c7904a7496e1bedb16b Mon Sep 17 00:00:00 2001 From: FFFFFFpy <87560048+FFFFFFpy@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:41:47 +0800 Subject: [PATCH 11/11] test: cover evidence diagnostics and media bundling --- tests/test_debug_evidence_media.py | 156 +++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/test_debug_evidence_media.py diff --git a/tests/test_debug_evidence_media.py b/tests/test_debug_evidence_media.py new file mode 100644 index 0000000..9a50158 --- /dev/null +++ b/tests/test_debug_evidence_media.py @@ -0,0 +1,156 @@ +import json +import zipfile +from pathlib import Path + +from okf_compiler import CompileOptions, compile_one +from okf_compiler.assets import collect_assets +from okf_compiler.llm import extract +from okf_compiler.markdown import split_sections +from okf_compiler.schema import Evidence, resolve_evidence + + +class FakeClient: + api_key = None + + def __init__(self, responses): + self.responses = iter(responses) + + def json_completion(self, system, user): + return json.dumps(next(self.responses), ensure_ascii=False) + + +def test_quote_evidence_resolves_absolute_lines(): + markdown = "# Title\n\n## First\nalpha line\nsecond line\n\n## Second\nbeta line\n" + sections = split_sections(markdown).sections + result = resolve_evidence( + Evidence(section_id="s0001", quote="alpha line second line"), + sections, + markdown, + ) + assert result.valid + assert result.reason == "ok_normalized_quote" + assert (result.evidence.line_start, result.evidence.line_end) == (4, 5) + + +def test_collects_markdown_images_and_html_video(tmp_path: Path): + source = tmp_path / "source" + source.mkdir() + assets = source / "assets" + assets.mkdir() + (assets / "photo.webp").write_bytes(b"image") + (assets / "clip.mp4").write_bytes(b"video") + markdown = ( + "![photo](assets/photo.webp)\n" + '\n' + ) + workdir = tmp_path / "work" + sections, normalized, refs, warnings = collect_assets([markdown], markdown, source, workdir) + assert not warnings + assert "../assets/images/photo.webp" in sections[0] + assert "../assets/media/clip.mp4" in sections[0] + assert "../assets/media/clip.mp4" in normalized + assert (workdir / "assets/media/clip.mp4").read_bytes() == b"video" + assert {ref.kind for ref in refs} == {"image", "video"} + + +def test_quote_evidence_is_resolved_for_all_extraction_stages(): + markdown = "# T\n\n## Body\n赵云与阿斗是一款小游戏。\n短局设计增加广告展示。\n\n## End\n结束。\n" + sections = split_sections(markdown).sections + client = FakeClient( + [ + {"summary": "摘要"}, + { + "concepts": [ + { + "name": "短局设计", + "description": "短局", + "confidence": 0.9, + "evidence": {"section_id": "s0001", "quote": "短局设计增加广告展示。"}, + } + ] + }, + { + "entities": [ + { + "name": "赵云与阿斗", + "type": "game", + "description": "游戏", + "aliases": [], + "confidence": 0.9, + "evidence": {"section_id": "s0001", "quote": "赵云与阿斗是一款小游戏。"}, + } + ] + }, + { + "relations": [ + { + "subject": "赵云与阿斗", + "relation": "uses", + "object": "短局设计", + "note": "", + "evidence": {"section_id": "s0001", "quote": "短局设计增加广告展示。"}, + } + ] + }, + ] + ) + result = extract(client, markdown, sections, language="zh", max_concepts=12, max_entities=12) + assert len(result.concepts) == 1 + assert len(result.entities) == 1 + assert len(result.relations) == 1 + assert result.entities[0].evidence.line_start == 4 + assert not result.degraded + + +def test_invalid_quote_is_reported_as_degraded(): + markdown = "# T\n\n## Body\n真实内容。\n\n## End\n结束。\n" + sections = split_sections(markdown).sections + client = FakeClient( + [ + {"summary": "摘要"}, + {"concepts": []}, + { + "entities": [ + { + "name": "不存在", + "type": "thing", + "description": "", + "aliases": [], + "evidence": {"section_id": "s0001", "quote": "模型编造的证据"}, + } + ] + }, + {"relations": []}, + ] + ) + result = extract(client, markdown, sections, language="zh", max_concepts=12, max_entities=12) + assert result.degraded + assert result.stage_stats["entities"]["rejected"] == 1 + assert result.validation[0]["reason"] == "quote_not_found" + + +def test_compile_bundles_video_and_writes_debug_sidecar(tmp_path: Path): + source = tmp_path / "article" + source.mkdir() + assets = source / "assets" + assets.mkdir() + (assets / "clip.mp4").write_bytes(b"video") + markdown = source / "article.md" + markdown.write_text( + '# Demo\n\n## One\n\n\n## Two\nDone.\n', + encoding="utf-8", + ) + output = source / "article.okf.zip" + result = compile_one( + markdown, + output, + CompileOptions(no_llm=True, debug_dir=tmp_path / "debug"), + ) + assert result.successful + assert (result.debug_dir / "run.json").is_file() + with zipfile.ZipFile(output) as bundle: + assert "assets/media/clip.mp4" in bundle.namelist() + assert '../assets/media/clip.mp4' in bundle.read("sources/article.md").decode("utf-8") + manifest = json.loads(bundle.read("manifest.json")) + assert manifest["counts"]["media"] == 1 + assert manifest["compiler"]["version"]