diff --git a/5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py b/5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py new file mode 100644 index 00000000..330b3c47 --- /dev/null +++ b/5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +""" +Emit Ollama-compatible DeepSeek review answers and receipts. + +This is the canonical emitter for receipts with schema +`ollama_deepseek_review_receipt_v1` and +`ollama_deepseek_review_continuation_receipt_v1`. + +The integrity rule is deliberately strict: write the answer first, compute +`answer_sha256` from the bytes read back from disk, write the receipt, then +verify the receipt against the answer path before returning success. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +REPO = Path(__file__).resolve().parents[3] +DEFAULT_OUTPUT_DIR = REPO / "shared-data" / "artifacts" / "deepseek_review" +DEFAULT_ENDPOINT = "https://ollama.com/v1/chat/completions" +PROVIDER_TAG = "deepseek" +PRIMARY_SCHEMA = "ollama_deepseek_review_receipt_v1" +CONTINUATION_SCHEMA = "ollama_deepseek_review_continuation_receipt_v1" + + +@dataclass(frozen=True) +class EmittedReview: + answer_path: Path + receipt_path: Path + answer_sha256: str + prompt_sha256: str + + +def sha256_bytes(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def canonical_json_bytes(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def repo_relative(path: Path, repo_root: Path = REPO) -> str: + path = path.resolve() + repo_root = repo_root.resolve() + try: + return str(path.relative_to(repo_root)) + except ValueError: + return str(path) + + +def safe_slug(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("_") + return slug or "review" + + +def utc_timestamp() -> str: + return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def iso_from_stamp(stamp: str) -> str: + parsed = dt.datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=dt.timezone.utc) + return parsed.isoformat() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def read_context_bundle(paths: list[Path], repo_root: Path = REPO) -> tuple[str, list[str]]: + chunks: list[str] = [] + context_files: list[str] = [] + for path in paths: + body = read_text(path) + rel = repo_relative(path, repo_root) + chunks.append(f"\n\n===== FILE: {rel} =====\n{body}") + context_files.append(rel) + return "".join(chunks), context_files + + +def build_messages( + prompt: str, + context_text: str, + previous_answer_path: Path | None, +) -> list[dict[str, str]]: + if previous_answer_path is not None: + previous_answer = read_text(previous_answer_path) + user_content = ( + f"{previous_answer}\n\n" + "===== CONTINUATION REQUEST =====\n" + f"{prompt}" + ) + else: + user_content = ( + f"{context_text}\n\n" + "===== REVIEW REQUEST =====\n" + f"{prompt}" + ) + + return [ + { + "role": "system", + "content": ( + "You are an external mathematical reviewer. Answer only from " + "the provided context and make arithmetic checks explicit." + ), + }, + {"role": "user", "content": user_content}, + ] + + +def build_request_body( + model: str, + messages: list[dict[str, str]], + max_tokens: int, + temperature: float, +) -> dict[str, Any]: + return { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + } + + +def call_ollama_chat(endpoint: str, api_key: str | None, request_bytes: bytes) -> dict[str, Any]: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + elif not endpoint.startswith(("http://127.0.0.1", "http://localhost")): + raise RuntimeError("OLLAMA_API_KEY is required for non-local Ollama endpoints") + + request = urllib.request.Request( + endpoint, + data=request_bytes, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=600) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Ollama endpoint returned HTTP {exc.code}: {detail}") from exc + + +def extract_answer(response: dict[str, Any]) -> tuple[str, dict[str, int], list[str]]: + choices = response.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("response has no choices") + + message = choices[0].get("message", {}) + if not isinstance(message, dict): + raise ValueError("response choice has no message object") + + content = message.get("content", "") + if isinstance(content, str): + answer_text = content + else: + answer_text = json.dumps(content, ensure_ascii=False) + + usage_obj = response.get("usage", {}) + usage = { + "prompt_tokens": int(usage_obj.get("prompt_tokens", 0) or 0), + "completion_tokens": int(usage_obj.get("completion_tokens", 0) or 0), + "total_tokens": int(usage_obj.get("total_tokens", 0) or 0), + } + if usage["total_tokens"] == 0: + usage["total_tokens"] = usage["prompt_tokens"] + usage["completion_tokens"] + + return answer_text, usage, list(message.keys()) + + +def write_text_and_hash(path: Path, text: str) -> str: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return sha256_bytes(path.read_bytes()) + + +def write_receipt(path: Path, receipt: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(receipt, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def verify_receipt(receipt_path: Path, repo_root: Path = REPO) -> bool: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + schema = receipt.get("schema") + if schema not in {PRIMARY_SCHEMA, CONTINUATION_SCHEMA}: + raise ValueError(f"{receipt_path}: unknown schema {schema!r}") + + answer_path = repo_root / receipt["answer_path"] + actual = sha256_bytes(answer_path.read_bytes()) + expected = receipt.get("answer_sha256") + if actual != expected: + raise ValueError(f"{receipt_path}: answer_sha256 mismatch: {actual} != {expected}") + + if schema == PRIMARY_SCHEMA: + if not isinstance(receipt.get("context_files"), list): + raise ValueError(f"{receipt_path}: primary receipt missing context_files") + if "previous_answer_path" in receipt: + raise ValueError(f"{receipt_path}: primary receipt must not carry previous_answer_path") + else: + if not receipt.get("previous_answer_path"): + raise ValueError(f"{receipt_path}: continuation receipt missing previous_answer_path") + if "context_files" in receipt: + raise ValueError(f"{receipt_path}: continuation receipt must omit context_files") + + return True + + +def emit_review( + *, + topic: str, + model: str, + prompt: str, + context_paths: list[Path], + previous_answer_path: Path | None, + output_dir: Path = DEFAULT_OUTPUT_DIR, + endpoint: str = DEFAULT_ENDPOINT, + max_tokens: int = 5000, + temperature: float = 0.0, + timestamp: str | None = None, + api_key: str | None = None, + response: dict[str, Any] | None = None, + repo_root: Path = REPO, +) -> EmittedReview: + stamp = timestamp or utc_timestamp() + created_at = iso_from_stamp(stamp) + context_text, context_files = read_context_bundle(context_paths, repo_root) + messages = build_messages(prompt, context_text, previous_answer_path) + request_body = build_request_body(model, messages, max_tokens, temperature) + request_bytes = canonical_json_bytes(request_body) + prompt_sha256 = sha256_bytes(request_bytes) + + if response is None: + response = call_ollama_chat(endpoint, api_key or os.getenv("OLLAMA_API_KEY"), request_bytes) + + answer_text, usage, message_keys = extract_answer(response) + stem = f"{safe_slug(topic)}_{PROVIDER_TAG}_{safe_slug(model)}" + if previous_answer_path is not None: + stem += "_continuation" + stem += f"_{stamp}" + + answer_path = output_dir / f"{stem}.md" + receipt_path = output_dir / f"{stem}.receipt.json" + answer_sha256 = write_text_and_hash(answer_path, answer_text) + + receipt: dict[str, Any] = { + "schema": CONTINUATION_SCHEMA if previous_answer_path else PRIMARY_SCHEMA, + "created_at": created_at, + "model": model, + "endpoint": endpoint, + "prompt_sha256": prompt_sha256, + "answer_sha256": answer_sha256, + "usage": usage, + "answer_path": repo_relative(answer_path, repo_root), + } + + if previous_answer_path is None: + receipt["context_files"] = context_files + else: + receipt["previous_answer_path"] = repo_relative(previous_answer_path, repo_root) + if message_keys: + receipt["message_keys"] = message_keys + + write_receipt(receipt_path, receipt) + verify_receipt(receipt_path, repo_root) + return EmittedReview(answer_path, receipt_path, answer_sha256, prompt_sha256) + + +def verify_receipt_dir(path: Path, repo_root: Path = REPO) -> int: + count = 0 + for receipt_path in sorted(path.glob("*.receipt.json")): + verify_receipt(receipt_path, repo_root) + count += 1 + return count + + +def read_prompt(args: argparse.Namespace) -> str: + if args.prompt_file: + return read_text(Path(args.prompt_file)) + if args.prompt: + return args.prompt + return sys.stdin.read() + + +def _cli() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("prompt", nargs="?", help="review prompt; stdin is used if omitted") + parser.add_argument("--prompt-file", help="read review prompt from a file") + parser.add_argument("--topic", default="review", help="artifact topic slug") + parser.add_argument("--model", default="deepseek-v3.2") + parser.add_argument("--endpoint", default=os.getenv("OLLAMA_ENDPOINT", DEFAULT_ENDPOINT)) + parser.add_argument("--context-file", action="append", default=[], help="primary review context file") + parser.add_argument("--continue-from", help="previous answer markdown for continuation receipts") + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--max-tokens", type=int, default=5000) + parser.add_argument("--temperature", type=float, default=0.0) + parser.add_argument("--timestamp", help="UTC stamp YYYYMMDDTHHMMSSZ; mainly for tests/replay") + parser.add_argument("--answer-text-file", help="offline mode: write this answer text instead of calling Ollama") + parser.add_argument("--verify-only", nargs="*", help="verify receipt paths; with no paths, verify --output-dir") + args = parser.parse_args() + + output_dir = Path(args.output_dir) + if args.verify_only is not None: + if args.verify_only: + for receipt in args.verify_only: + verify_receipt(Path(receipt)) + print(f"verified {receipt}") + else: + count = verify_receipt_dir(output_dir) + print(f"verified {count} receipts in {output_dir}") + return + + prompt = read_prompt(args) + response = None + if args.answer_text_file: + answer_text = read_text(Path(args.answer_text_file)) + response = { + "choices": [{"message": {"role": "assistant", "content": answer_text}}], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + emitted = emit_review( + topic=args.topic, + model=args.model, + prompt=prompt, + context_paths=[Path(p) for p in args.context_file], + previous_answer_path=Path(args.continue_from) if args.continue_from else None, + output_dir=output_dir, + endpoint=args.endpoint, + max_tokens=args.max_tokens, + temperature=args.temperature, + timestamp=args.timestamp, + response=response, + ) + print(json.dumps({ + "answer_path": repo_relative(emitted.answer_path), + "receipt_path": repo_relative(emitted.receipt_path), + "answer_sha256": emitted.answer_sha256, + "prompt_sha256": emitted.prompt_sha256, + }, indent=2)) + + +if __name__ == "__main__": + _cli() diff --git a/5-Applications/tools-scripts/llm/test_ollama_deepseek_review_emitter.py b/5-Applications/tools-scripts/llm/test_ollama_deepseek_review_emitter.py new file mode 100644 index 00000000..b1ad8b68 --- /dev/null +++ b/5-Applications/tools-scripts/llm/test_ollama_deepseek_review_emitter.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("ollama_deepseek_review_emitter.py") +SPEC = importlib.util.spec_from_file_location("ollama_deepseek_review_emitter", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +emitter = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = emitter +SPEC.loader.exec_module(emitter) + + +class OllamaDeepSeekReviewEmitterTest(unittest.TestCase): + def test_primary_receipt_hashes_answer_after_write(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + context = root / "context.md" + context.write_text("canonical arithmetic context", encoding="utf-8") + output = root / "deepseek_review" + + emitted = emitter.emit_review( + topic="prime_gap_entropy_collapse", + model="deepseek-v3.2", + prompt="Review this.", + context_paths=[context], + previous_answer_path=None, + output_dir=output, + endpoint="http://localhost:11434/v1/chat/completions", + timestamp="20260512T000000Z", + response={ + "choices": [{"message": {"role": "assistant", "content": "answer body"}}], + "usage": {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5}, + }, + repo_root=root, + ) + + receipt = json.loads(emitted.receipt_path.read_text(encoding="utf-8")) + self.assertEqual(receipt["schema"], emitter.PRIMARY_SCHEMA) + self.assertEqual(receipt["context_files"], ["context.md"]) + self.assertEqual(receipt["answer_sha256"], emitter.sha256_bytes(emitted.answer_path.read_bytes())) + self.assertTrue(emitter.verify_receipt(emitted.receipt_path, root)) + + def test_continuation_omits_context_and_points_to_previous_answer(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + previous = root / "previous.md" + previous.write_text("first answer", encoding="utf-8") + output = root / "deepseek_review" + + emitted = emitter.emit_review( + topic="prime_gap_entropy_collapse", + model="deepseek-v4-flash", + prompt="Continue.", + context_paths=[], + previous_answer_path=previous, + output_dir=output, + endpoint="http://localhost:11434/v1/chat/completions", + timestamp="20260512T000100Z", + response={ + "choices": [{"message": {"role": "assistant", "content": "continued", "reasoning": "brief"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + repo_root=root, + ) + + receipt = json.loads(emitted.receipt_path.read_text(encoding="utf-8")) + self.assertEqual(receipt["schema"], emitter.CONTINUATION_SCHEMA) + self.assertNotIn("context_files", receipt) + self.assertEqual(receipt["previous_answer_path"], "previous.md") + self.assertEqual(receipt["message_keys"], ["role", "content", "reasoning"]) + self.assertTrue(emitter.verify_receipt(emitted.receipt_path, root)) + + def test_verify_receipt_rejects_stale_answer_hash(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + context = root / "context.md" + context.write_text("context", encoding="utf-8") + output = root / "deepseek_review" + emitted = emitter.emit_review( + topic="review", + model="deepseek-v3.2", + prompt="Review.", + context_paths=[context], + previous_answer_path=None, + output_dir=output, + endpoint="http://localhost:11434/v1/chat/completions", + timestamp="20260512T000200Z", + response={"choices": [{"message": {"role": "assistant", "content": "stable"}}]}, + repo_root=root, + ) + + emitted.answer_path.write_text("mutated", encoding="utf-8") + with self.assertRaises(ValueError): + emitter.verify_receipt(emitted.receipt_path, root) + + +if __name__ == "__main__": + unittest.main() diff --git a/6-Documentation/wiki/DeepSeek-Review-Process.md b/6-Documentation/wiki/DeepSeek-Review-Process.md index 6a8e5833..3eddfe33 100644 --- a/6-Documentation/wiki/DeepSeek-Review-Process.md +++ b/6-Documentation/wiki/DeepSeek-Review-Process.md @@ -10,12 +10,11 @@ of the review pipeline, the receipt schema, and the canonical example `shared-data/artifacts/deepseek_review/`. Important boundary: the canonical artifacts documented on this page are -Ollama-compatible review receipts. The tracked -`5-Applications/tools-scripts/llm/deepseek_review_adapter.py` is a separate -Anthropic-compatible DeepSeek adapter with its own `schema_version: "1.0"` -receipt format. Do not use that adapter as the emitter for the -`ollama_deepseek_review_receipt_v1` schema unless it has first been reconciled -to this schema. +Ollama-compatible review receipts. Use +`5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py` for this +schema. The tracked `5-Applications/tools-scripts/llm/deepseek_review_adapter.py` +is a separate Anthropic-compatible DeepSeek adapter with its own +`schema_version: "1.0"` receipt format. --- @@ -52,10 +51,12 @@ Anthropic-compatible DeepSeek reviews. The artifacts in `shared-data/artifacts/deepseek_review/` instead record the Ollama-compatible schema documented below: `schema`, `created_at`, `endpoint`, `usage.{prompt_tokens,completion_tokens,total_tokens}`, plain string -`context_files`, and `answer_sha256`. As of this page, no tracked canonical -Ollama emitter script has been identified in the repository. Treat the checked -receipts themselves as the schema authority until such an emitter is added or -the adapter is reconciled. +`context_files`, and `answer_sha256`. The canonical emitter is +`5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py`; it writes +the answer file first, computes `answer_sha256` from bytes read back from disk, +writes the receipt, and immediately verifies the receipt against the answer +path before returning success. This write-time verification gate exists because +the first manually landed Ollama receipts had stale `answer_sha256` values. ### Receipt Schema @@ -188,9 +189,11 @@ When emitting new review artifacts: 2. Write the matching receipt alongside it with the same stem and `.receipt.json` suffix, using `ollama_deepseek_review_receipt_v1` for the primary review and `ollama_deepseek_review_continuation_receipt_v1` for any - continuation. Do not generate these receipts with - `5-Applications/tools-scripts/llm/deepseek_review_adapter.py`; it emits a - different Anthropic-compatible schema unless explicitly updated. + continuation. Use + `5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py`; do not + generate these receipts with + `5-Applications/tools-scripts/llm/deepseek_review_adapter.py`, which emits a + different Anthropic-compatible schema. 3. Populate `context_files` with repo-relative paths to every file consumed by the prompt so future agents can reproduce the prompt body. Continuation receipts omit `context_files` and `message_keys` records the alternate @@ -208,6 +211,9 @@ When emitting new review artifacts: - [[Build-System]] — pinned Python interpreter for review tooling and prompt-hash reproducibility across runs. +- `5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py` — + canonical Ollama-compatible emitter for this schema; verifies + `answer_sha256` after writing. - `5-Applications/tools-scripts/llm/deepseek_review_adapter.py` — Anthropic-compatible DeepSeek adapter with a different receipt schema; useful as related infrastructure, but not the emitter for the current Ollama-style diff --git a/6-Documentation/wiki/Home.md b/6-Documentation/wiki/Home.md index 71c5e9c8..4c19f532 100644 --- a/6-Documentation/wiki/Home.md +++ b/6-Documentation/wiki/Home.md @@ -81,4 +81,5 @@ |---|---| | `wiki/DeepSeek-Review-Process.md` | Receipt schema (`ollama_deepseek_review_receipt_v1`), two-stage pipeline (`deepseek-v3.2` + `deepseek-v4-flash` continuation), prime-gap entropy-collapse example | | `shared-data/artifacts/deepseek_review/` | Paired answer + receipt artifacts for each review run | +| `5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py` | Canonical Ollama-compatible DeepSeek review emitter with write-time answer-hash verification | | `5-Applications/tools-scripts/llm/deepseek_review_adapter.py` | Related Anthropic-compatible DeepSeek adapter; it does not emit the current Ollama-style receipt schema | diff --git a/AGENTS.md b/AGENTS.md index ae7a0185..5c21fc93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ This file is the first stop for coding agents working in this repository. - Documentation and wiki surfaces: `6-Documentation/` - Stack receipts: `shared-data/data/stack_solidification/` - Promoted review receipts: `shared-data/artifacts/deepseek_review/` +- Canonical Ollama/DeepSeek review emitter: + `5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py` - CAD harness: `5-Applications/text-to-cad/` - Historical scoped staging maps: `6-Documentation/docs/stack_solidification_staging_manifest_2026-05-09.md` @@ -34,6 +36,9 @@ This file is the first stop for coding agents working in this repository. - For Lean changes, run the narrow target first, then the broader `lake build` when feasible. - For Python shims, run `python3 -m py_compile` on touched files. - For JSON receipts, run `python3 -m json.tool` or a repo-native receipt parser. +- For promoted Ollama/DeepSeek review receipts, run + `python3 5-Applications/tools-scripts/llm/ollama_deepseek_review_emitter.py --verify-only` + so `answer_sha256` is checked against the answer file after write. - For hardware claims, distinguish software witness, bitstream presence, SRAM load, flash persistence, UART beacon, and live hardware receipt. - Before committing, run `git diff --cached --check` and a staged secret scan for touched source/receipt files. The repository credential hook is a final