文件预览

runtime_contract.py

查看 Audible Goodreads Deal Scout 技能包中的文件内容。

文件内容

audible_goodreads_deal_scout/runtime_contract.py

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from .constants import DEFAULT_THRESHOLD
from .shared import atomic_write_text, normalize_space, write_json_atomic


def runtime_output_schema() -> dict[str, Any]:
    return {
        "schemaVersion": 1,
        "type": "object",
        "required": ["schemaVersion", "goodreads", "fit"],
        "properties": {
            "schemaVersion": {"const": 1},
            "goodreads": {
                "type": "object",
                "required": ["status"],
                "properties": {
                    "status": {
                        "enum": ["resolved", "no_match", "lookup_failed"],
                    },
                    "url": {"type": ["string", "null"]},
                    "title": {"type": ["string", "null"]},
                    "author": {"type": ["string", "null"]},
                    "averageRating": {"type": ["number", "null"]},
                    "ratingsCount": {"type": ["integer", "null"]},
                    "evidence": {"type": ["string", "null"]},
                },
            },
            "fit": {
                "type": "object",
                "required": ["status"],
                "properties": {
                    "status": {
                        "enum": ["written", "not_applicable", "unavailable"],
                    },
                    "sentence": {"type": ["string", "null"]},
                },
            },
        },
    }


def build_runtime_input(prep_result: dict[str, Any]) -> dict[str, Any]:
    metadata = dict(prep_result.get("metadata") or {})
    personal_data = dict(prep_result.get("personalData") or {})
    exact_shelf = normalize_space(str(personal_data.get("exactShelfMatch") or ""))
    csv_data = dict(personal_data.get("csv") or {})
    context_budget = dict(csv_data.get("contextBudget") or {})
    artifact_paths = dict(prep_result.get("artifacts") or {})
    return {
        "schemaVersion": 1,
        "decisionContract": {
            "threshold": metadata.get("threshold", DEFAULT_THRESHOLD),
            "exactShelfMatch": exact_shelf,
            "toReadOverridesThreshold": True,
            "readAndCurrentlyReadingSuppress": True,
        },
        "audible": prep_result.get("audible") or {},
        "personalDataSummary": {
            "mode": personal_data.get("mode"),
            "privacyMode": personal_data.get("privacyMode"),
            "allowModelPersonalization": personal_data.get("allowModelPersonalization"),
            "exactShelfMatch": exact_shelf,
            "matchedEntryCount": len(personal_data.get("matchedEntries") or []),
            "csvRatedOrReviewedCount": int(csv_data.get("ratedOrReviewedCount") or 0),
            "csvReviewedCount": int(csv_data.get("reviewedCount") or 0),
            "fitContextApproxTokens": int(context_budget.get("estimatedFinalApproxTokens") or 0)
            if artifact_paths.get("fitContextPath")
            else 0,
            "notesPresent": bool(artifact_paths.get("notesPath")),
        },
        "artifactPaths": artifact_paths,
        "warnings": list(prep_result.get("warnings") or []),
        "requiredRuntimeOutputSchema": runtime_output_schema(),
    }


def build_runtime_prompt(runtime_input: dict[str, Any]) -> str:
    threshold = runtime_input["decisionContract"]["threshold"]
    exact_shelf = runtime_input["decisionContract"].get("exactShelfMatch") or ""
    artifact_paths = dict(runtime_input.get("artifactPaths") or {})
    lines = [
        "You are the skill runtime for audible-goodreads-deal-scout.",
        "Read the runtime input JSON and return JSON only.",
        "Do not invent fields outside the required runtime output schema.",
        "Use OpenClaw web/search to locate the Goodreads public book page and score when needed.",
        "Prefer Goodreads book pages over list, author, or discussion pages.",
        "Verify the Goodreads title/author match against the Audible title and author before trusting the score.",
        f"The public Goodreads threshold is {threshold:.1f}.",
    ]
    if exact_shelf == "to-read":
        lines.append("This book is already on the user's Goodreads to-read shelf. Goodreads lookup is optional for decisioning; a fit sentence is still useful.")
    else:
        lines.append("If Goodreads cannot be confidently matched, return goodreads.status = \"no_match\" or \"lookup_failed\" instead of guessing.")
    lines.extend(
        [
            "Fit generation rules:",
            "- If privacyMode is minimal, do not use personal CSV or notes content.",
            "- Use artifacts.fitContextPath as the primary CSV taste artifact when that file is present. It keeps every rated/reviewed book and strips low-value metadata.",
            "- If artifacts.reviewSourcePath exists, summarize each review-bearing entry to 500 characters or fewer before using it for fit reasoning. Do not mechanically truncate reviews.",
            "- Use artifacts.personalDataPath for summary metadata and exact shelf state, not for full taste history.",
            "- Write Fit as a compact paragraph, not a generic single sentence.",
            "- Preferred shape: 2 or 3 short sentences, roughly 45-90 words total.",
            "- Mention what is likely to appeal to the user and one concrete thing they may dislike or find limiting.",
            "- Avoid low-entropy filler like 'your Goodreads history shows interest' unless followed by specific taste detail.",
            "- If exactShelfMatch is to-read, mention that explicitly in the fit paragraph.",
            "- If there is no meaningful personal data, set fit.status to \"not_applicable\".",
            "- If the model cannot write a fit paragraph reliably, set fit.status to \"unavailable\".",
        ]
    )
    if not any(artifact_paths.get(key) for key in ("fitContextPath", "reviewSourcePath", "notesPath")):
        lines.append("- No personal CSV or notes artifacts are provided for this run beyond summary metadata and shelf state.")
    lines.extend(
        [
            "",
            "Required runtime output schema:",
            json.dumps(runtime_output_schema(), indent=2, sort_keys=True, ensure_ascii=False),
            "",
            "Runtime input JSON:",
            json.dumps(runtime_input, indent=2, sort_keys=True, ensure_ascii=False),
        ]
    )
    return "\n".join(lines) + "\n"


def write_runtime_contract_artifacts(artifact_dir: Path, prep_result: dict[str, Any]) -> dict[str, str]:
    runtime_input = build_runtime_input(prep_result)
    runtime_input_path = artifact_dir / "runtime-input.json"
    runtime_prompt_path = artifact_dir / "runtime-prompt.md"
    runtime_schema_path = artifact_dir / "runtime-output-schema.json"
    write_json_atomic(runtime_input_path, runtime_input)
    atomic_write_text(runtime_prompt_path, build_runtime_prompt(runtime_input))
    write_json_atomic(runtime_schema_path, runtime_output_schema())
    return {
        "runtimeInputPath": str(runtime_input_path),
        "runtimePromptPath": str(runtime_prompt_path),
        "runtimeOutputSchemaPath": str(runtime_schema_path),
    }


def attach_prepare_result_artifact(artifact_dir: Path, prep_result: dict[str, Any]) -> dict[str, Any]:
    prepare_result_path = artifact_dir / "prepare-result.json"
    prep_result.setdefault("artifacts", {})["prepareResultPath"] = str(prepare_result_path)
    write_json_atomic(prepare_result_path, prep_result)
    return prep_result


def attach_runtime_contract_artifacts(artifact_dir: Path, prep_result: dict[str, Any]) -> dict[str, Any]:
    runtime_artifacts = write_runtime_contract_artifacts(artifact_dir, prep_result)
    prep_result.setdefault("artifacts", {}).update(runtime_artifacts)
    return attach_prepare_result_artifact(artifact_dir, prep_result)