From 22e1573d37a3073fde378303ccdd0f3c6274c3a7 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 08:42:44 -0500 Subject: [PATCH 01/12] feat: composable prompt system (recall#794) Implements buildExtractionPrompt() in both TypeScript and Python. - 17 capability prompt fragments (prompts/v1/*.txt) per locked spec - Shared preamble/postamble with Mustache-style template variables - 3 profile files (minimal/standard/full) as capability set shorthand - Capability dependency closure (e.g., relations auto-includes entity_ids) - Canonical composition order: primary objects, modifiers, cross-cutting - Capability-specific rules appended before the text block - Profile add/remove API for fine-grained customization - resolve_capabilities() exposed for introspection - 53 new Python tests, all 109 tests passing - TypeScript type-checks clean Co-Authored-By: Claude Opus 4.6 --- .../python/src/synapt_extract/__init__.py | 3 + packages/python/src/synapt_extract/prompt.py | 162 ++++++++ packages/ts/package-lock.json | 18 + packages/ts/package.json | 1 + packages/ts/src/index.ts | 3 + packages/ts/src/prompt.ts | 158 ++++++++ prompts/profiles/full.json | 11 + prompts/profiles/minimal.json | 3 + prompts/profiles/standard.json | 10 + prompts/v1/assertion_signals.txt | 1 + prompts/v1/entities.txt | 1 + prompts/v1/entity_context.txt | 1 + prompts/v1/entity_ids.txt | 1 + prompts/v1/entity_state.txt | 1 + prompts/v1/evidence_anchoring.txt | 1 + prompts/v1/facts.txt | 1 + prompts/v1/goal_entity_refs.txt | 1 + prompts/v1/goal_timing.txt | 1 + prompts/v1/goals.txt | 1 + prompts/v1/postamble.txt | 7 + prompts/v1/preamble.txt | 8 + prompts/v1/relation_origin.txt | 1 + prompts/v1/relations.txt | 1 + prompts/v1/sentiment.txt | 1 + prompts/v1/summary.txt | 1 + prompts/v1/temporal_classes.txt | 1 + prompts/v1/temporal_refs.txt | 1 + prompts/v1/themes.txt | 1 + tests/python/test_prompt.py | 365 ++++++++++++++++++ 29 files changed, 766 insertions(+) create mode 100644 packages/python/src/synapt_extract/prompt.py create mode 100644 packages/ts/src/prompt.ts create mode 100644 prompts/profiles/full.json create mode 100644 prompts/profiles/minimal.json create mode 100644 prompts/profiles/standard.json create mode 100644 prompts/v1/assertion_signals.txt create mode 100644 prompts/v1/entities.txt create mode 100644 prompts/v1/entity_context.txt create mode 100644 prompts/v1/entity_ids.txt create mode 100644 prompts/v1/entity_state.txt create mode 100644 prompts/v1/evidence_anchoring.txt create mode 100644 prompts/v1/facts.txt create mode 100644 prompts/v1/goal_entity_refs.txt create mode 100644 prompts/v1/goal_timing.txt create mode 100644 prompts/v1/goals.txt create mode 100644 prompts/v1/postamble.txt create mode 100644 prompts/v1/preamble.txt create mode 100644 prompts/v1/relation_origin.txt create mode 100644 prompts/v1/relations.txt create mode 100644 prompts/v1/sentiment.txt create mode 100644 prompts/v1/summary.txt create mode 100644 prompts/v1/temporal_classes.txt create mode 100644 prompts/v1/temporal_refs.txt create mode 100644 prompts/v1/themes.txt create mode 100644 tests/python/test_prompt.py diff --git a/packages/python/src/synapt_extract/__init__.py b/packages/python/src/synapt_extract/__init__.py index 8047f22..3ed5e29 100644 --- a/packages/python/src/synapt_extract/__init__.py +++ b/packages/python/src/synapt_extract/__init__.py @@ -13,6 +13,7 @@ ) from synapt_extract.validate import validate_extraction, ValidationResult, ValidationError from synapt_extract.finalize import finalize_extraction, FinalizeContext, FinalizeResult +from synapt_extract.prompt import build_extraction_prompt, resolve_capabilities __all__ = [ "SynaptExtraction", @@ -30,4 +31,6 @@ "finalize_extraction", "FinalizeContext", "FinalizeResult", + "build_extraction_prompt", + "resolve_capabilities", ] diff --git a/packages/python/src/synapt_extract/prompt.py b/packages/python/src/synapt_extract/prompt.py new file mode 100644 index 0000000..c0dc8d2 --- /dev/null +++ b/packages/python/src/synapt_extract/prompt.py @@ -0,0 +1,162 @@ +"""Composable prompt system for SynaptExtraction IL v1.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +PROMPTS_DIR = Path(__file__).resolve().parents[4] / "prompts" + +CAPABILITY_DEPS: dict[str, list[str]] = { + "entity_state": ["entities"], + "entity_context": ["entities"], + "entity_ids": ["entities"], + "goal_timing": ["goals"], + "goal_entity_refs": ["goals", "entity_ids"], + "temporal_classes": ["temporal_refs"], + "relations": ["entities", "entity_ids"], + "relation_origin": ["relations"], +} + +CANONICAL_ORDER = [ + "entities", "goals", "themes", "summary", "sentiment", "facts", "temporal_refs", + "entity_state", "entity_context", "entity_ids", + "goal_timing", "goal_entity_refs", + "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring", +] + +CAPABILITY_RULES: dict[str, str] = { + "entity_ids": 'Assign each entity a short local ID ("e1", "e2", etc.). Goals and relations reference entities by ID.', + "temporal_refs": "Resolve all relative dates to absolute dates.", + "relation_origin": 'Mark relation origin: "explicit" if stated in text, "inferred" if deduced from context, "dependent" if derived from another relation.', + "assertion_signals": 'Preserve negation, hedging, and conditions in signals. "I might move" → hedged=true. "No longer using Redis" → negated=true. "If we get funding" → condition="we get funding".', +} + + +def _load_profile(name: str) -> list[str]: + path = PROMPTS_DIR / "profiles" / f"{name}.json" + if not path.exists(): + raise ValueError(f"Unknown profile: {name}") + data = json.loads(path.read_text()) + return data["capabilities"] + + +def _load_fragment(name: str) -> str: + path = PROMPTS_DIR / "v1" / f"{name}.txt" + return path.read_text() + + +def _render_template(template: str, context: dict[str, Any]) -> str: + def replace_if(match: re.Match) -> str: + var = match.group(1) + body = match.group(2) + if context.get(var): + return _render_vars(body, context) + return "" + + result = re.sub(r"\{\{#if (\w+)\}\}(.*?)\{\{/if\}\}", replace_if, template, flags=re.DOTALL) + return _render_vars(result, context) + + +def _render_vars(template: str, context: dict[str, Any]) -> str: + def replace_var(match: re.Match) -> str: + var = match.group(1) + val = context.get(var, "") + if isinstance(val, list): + return ", ".join(str(v) for v in val) + return str(val) + + return re.sub(r"\{\{(\w+)\}\}", replace_var, template) + + +def resolve_capabilities( + *, + capabilities: list[str] | None = None, + profile: str | None = None, + add: list[str] | None = None, + remove: list[str] | None = None, +) -> list[str]: + if capabilities is None and profile is None: + raise ValueError("Either capabilities or profile must be provided") + + if capabilities is not None: + caps = set(capabilities) + else: + caps = set(_load_profile(profile)) + + if add: + caps.update(add) + if remove: + caps -= set(remove) + + changed = True + while changed: + changed = False + for cap in list(caps): + for dep in CAPABILITY_DEPS.get(cap, []): + if dep not in caps: + caps.add(dep) + changed = True + + return sorted(caps, key=lambda c: CANONICAL_ORDER.index(c) if c in CANONICAL_ORDER else len(CANONICAL_ORDER)) + + +def build_extraction_prompt( + text: str, + *, + capabilities: list[str] | None = None, + profile: str | None = None, + add: list[str] | None = None, + remove: list[str] | None = None, + categories: list[str] | None = None, + source_type: str | None = None, + date: str | None = None, +) -> str: + if capabilities is not None and profile is not None: + raise ValueError("Cannot specify both capabilities and profile") + + resolved = resolve_capabilities( + capabilities=capabilities, + profile=profile, + add=add, + remove=remove, + ) + + template_ctx: dict[str, Any] = { + "text": text, + "categories": categories, + "source_type": source_type, + "date": date, + } + + parts: list[str] = [] + + preamble = _render_template(_load_fragment("preamble"), template_ctx) + parts.append(preamble.strip()) + + for cap in resolved: + fragment = _render_template(_load_fragment(cap), template_ctx) + parts.append(fragment.rstrip()) + + rules_section: list[str] = [] + for cap in resolved: + rule = CAPABILITY_RULES.get(cap) + if rule: + rules_section.append(rule) + + postamble_template = _load_fragment("postamble") + if rules_section: + extra_rules = "\n".join(f"- {r}" for r in rules_section) + postamble_rendered = _render_template(postamble_template, template_ctx).rstrip() + idx = postamble_rendered.find("\nText:") + if idx >= 0: + postamble_rendered = postamble_rendered[:idx] + "\n" + extra_rules + postamble_rendered[idx:] + parts.append(postamble_rendered) + else: + parts.append(_render_template(postamble_template, template_ctx).rstrip()) + + return "\n".join(parts) + "\n" diff --git a/packages/ts/package-lock.json b/packages/ts/package-lock.json index 566f26f..868e4e2 100644 --- a/packages/ts/package-lock.json +++ b/packages/ts/package-lock.json @@ -9,12 +9,23 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@types/node": "^25.6.0", "typescript": "^5.5.0" }, "engines": { "node": ">=18" } }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -28,6 +39,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/ts/package.json b/packages/ts/package.json index 0c8ece5..d1265d9 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -37,6 +37,7 @@ "node": ">=18" }, "devDependencies": { + "@types/node": "^25.6.0", "typescript": "^5.5.0" } } diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 9a4a27f..51b3178 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -16,3 +16,6 @@ export type { ValidationResult, ValidationError } from "./validate.js"; export { finalizeExtraction } from "./finalize.js"; export type { FinalizeContext, FinalizeResult } from "./finalize.js"; + +export { buildExtractionPrompt, resolveCapabilities } from "./prompt.js"; +export type { PromptOptions } from "./prompt.js"; diff --git a/packages/ts/src/prompt.ts b/packages/ts/src/prompt.ts new file mode 100644 index 0000000..d2fea74 --- /dev/null +++ b/packages/ts/src/prompt.ts @@ -0,0 +1,158 @@ +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ExtractionCapability } from "./schema.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROMPTS_DIR = resolve(__dirname, "..", "..", "..", "prompts"); + +export interface PromptOptions { + capabilities?: ExtractionCapability[]; + profile?: "minimal" | "standard" | "full"; + add?: ExtractionCapability[]; + remove?: ExtractionCapability[]; + categories?: string[]; + source_type?: string; + date?: string; +} + +const CAPABILITY_DEPS: Partial> = { + entity_state: ["entities"], + entity_context: ["entities"], + entity_ids: ["entities"], + goal_timing: ["goals"], + goal_entity_refs: ["goals", "entity_ids"], + temporal_classes: ["temporal_refs"], + relations: ["entities", "entity_ids"], + relation_origin: ["relations"], +}; + +const CANONICAL_ORDER: ExtractionCapability[] = [ + "entities", "goals", "themes", "summary", "sentiment", "facts", "temporal_refs", + "entity_state", "entity_context", "entity_ids", + "goal_timing", "goal_entity_refs", + "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring", +]; + +const CAPABILITY_RULES: Partial> = { + entity_ids: 'Assign each entity a short local ID ("e1", "e2", etc.). Goals and relations reference entities by ID.', + temporal_refs: "Resolve all relative dates to absolute dates.", + relation_origin: 'Mark relation origin: "explicit" if stated in text, "inferred" if deduced from context, "dependent" if derived from another relation.', + assertion_signals: 'Preserve negation, hedging, and conditions in signals. "I might move" → hedged=true. "No longer using Redis" → negated=true. "If we get funding" → condition="we get funding".', +}; + +function loadProfile(name: string): ExtractionCapability[] { + const path = resolve(PROMPTS_DIR, "profiles", `${name}.json`); + const data = JSON.parse(readFileSync(path, "utf-8")) as { capabilities: ExtractionCapability[] }; + return data.capabilities; +} + +function loadFragment(name: string): string { + const path = resolve(PROMPTS_DIR, "v1", `${name}.txt`); + return readFileSync(path, "utf-8"); +} + +function renderTemplate(template: string, ctx: Record): string { + let result = template.replace( + /\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, varName: string, body: string) => { + return ctx[varName] ? renderVars(body, ctx) : ""; + }, + ); + return renderVars(result, ctx); +} + +function renderVars(template: string, ctx: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, varName: string) => { + const val = ctx[varName]; + if (Array.isArray(val)) return val.join(", "); + return val != null ? String(val) : ""; + }); +} + +export function resolveCapabilities(options: Pick): ExtractionCapability[] { + let caps: Set; + + if (options.capabilities != null) { + caps = new Set(options.capabilities); + } else if (options.profile != null) { + caps = new Set(loadProfile(options.profile)); + } else { + throw new Error("Either capabilities or profile must be provided"); + } + + if (options.add) { + for (const c of options.add) caps.add(c); + } + if (options.remove) { + for (const c of options.remove) caps.delete(c); + } + + let changed = true; + while (changed) { + changed = false; + for (const cap of [...caps]) { + const deps = CAPABILITY_DEPS[cap]; + if (deps) { + for (const dep of deps) { + if (!caps.has(dep)) { + caps.add(dep); + changed = true; + } + } + } + } + } + + return [...caps].sort((a, b) => { + const ai = CANONICAL_ORDER.indexOf(a); + const bi = CANONICAL_ORDER.indexOf(b); + return (ai === -1 ? CANONICAL_ORDER.length : ai) - (bi === -1 ? CANONICAL_ORDER.length : bi); + }); +} + +export function buildExtractionPrompt(text: string, options: PromptOptions): string { + if (options.capabilities != null && options.profile != null) { + throw new Error("Cannot specify both capabilities and profile"); + } + + const resolved = resolveCapabilities(options); + + const ctx: Record = { + text, + categories: options.categories, + source_type: options.source_type, + date: options.date, + }; + + const parts: string[] = []; + + const preamble = renderTemplate(loadFragment("preamble"), ctx); + parts.push(preamble.trim()); + + for (const cap of resolved) { + const fragment = renderTemplate(loadFragment(cap), ctx); + parts.push(fragment.trimEnd()); + } + + const rulesSection: string[] = []; + for (const cap of resolved) { + const rule = CAPABILITY_RULES[cap]; + if (rule) rulesSection.push(rule); + } + + let postamble = renderTemplate(loadFragment("postamble"), ctx).trimEnd(); + if (rulesSection.length > 0) { + const extraRules = rulesSection.map((r) => `- ${r}`).join("\n"); + const idx = postamble.indexOf("\nText:"); + if (idx >= 0) { + postamble = postamble.slice(0, idx) + "\n" + extraRules + postamble.slice(idx); + } + } + parts.push(postamble); + + return parts.join("\n") + "\n"; +} diff --git a/prompts/profiles/full.json b/prompts/profiles/full.json new file mode 100644 index 0000000..581837f --- /dev/null +++ b/prompts/profiles/full.json @@ -0,0 +1,11 @@ +{ + "capabilities": [ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", + "facts", + "temporal_refs", "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring" + ] +} diff --git a/prompts/profiles/minimal.json b/prompts/profiles/minimal.json new file mode 100644 index 0000000..aa2b56d --- /dev/null +++ b/prompts/profiles/minimal.json @@ -0,0 +1,3 @@ +{ + "capabilities": ["entities", "entity_state", "goals", "themes", "summary"] +} diff --git a/prompts/profiles/standard.json b/prompts/profiles/standard.json new file mode 100644 index 0000000..4417221 --- /dev/null +++ b/prompts/profiles/standard.json @@ -0,0 +1,10 @@ +{ + "capabilities": [ + "entities", "entity_state", "entity_context", + "goals", "goal_timing", + "themes", "summary", "sentiment", + "facts", + "temporal_refs", + "evidence_anchoring" + ] +} diff --git a/prompts/v1/assertion_signals.txt b/prompts/v1/assertion_signals.txt new file mode 100644 index 0000000..239de67 --- /dev/null +++ b/prompts/v1/assertion_signals.txt @@ -0,0 +1 @@ + - on entities, goals, facts, and relations, include "signals": {"confidence": 0.0-1.0, "negated": boolean, "hedged": boolean, "condition": string} diff --git a/prompts/v1/entities.txt b/prompts/v1/entities.txt new file mode 100644 index 0000000..d9758f9 --- /dev/null +++ b/prompts/v1/entities.txt @@ -0,0 +1 @@ +- "entities": array of objects with "name" (string) and "type" ("person", "place", "event", "concept", "organization", "object") diff --git a/prompts/v1/entity_context.txt b/prompts/v1/entity_context.txt new file mode 100644 index 0000000..75746c7 --- /dev/null +++ b/prompts/v1/entity_context.txt @@ -0,0 +1 @@ + - include "context": role or relationship, and "date_hint": relevant date diff --git a/prompts/v1/entity_ids.txt b/prompts/v1/entity_ids.txt new file mode 100644 index 0000000..7b63edd --- /dev/null +++ b/prompts/v1/entity_ids.txt @@ -0,0 +1 @@ + - include "id": short local ID ("e1", "e2", etc.) diff --git a/prompts/v1/entity_state.txt b/prompts/v1/entity_state.txt new file mode 100644 index 0000000..eaf8a4a --- /dev/null +++ b/prompts/v1/entity_state.txt @@ -0,0 +1 @@ + - include "state": current state described in text diff --git a/prompts/v1/evidence_anchoring.txt b/prompts/v1/evidence_anchoring.txt new file mode 100644 index 0000000..eafb2c1 --- /dev/null +++ b/prompts/v1/evidence_anchoring.txt @@ -0,0 +1 @@ + - on entities, goals, and facts, include "source": {"snippet": verbatim quote from text} diff --git a/prompts/v1/facts.txt b/prompts/v1/facts.txt new file mode 100644 index 0000000..d2f16e5 --- /dev/null +++ b/prompts/v1/facts.txt @@ -0,0 +1 @@ +- "facts": array of objects with "text" and optional "category" diff --git a/prompts/v1/goal_entity_refs.txt b/prompts/v1/goal_entity_refs.txt new file mode 100644 index 0000000..ac54f6d --- /dev/null +++ b/prompts/v1/goal_entity_refs.txt @@ -0,0 +1 @@ + - include "entity_refs": array of entity IDs (not names) diff --git a/prompts/v1/goal_timing.txt b/prompts/v1/goal_timing.txt new file mode 100644 index 0000000..9aef702 --- /dev/null +++ b/prompts/v1/goal_timing.txt @@ -0,0 +1 @@ + - include "stated_at" and "resolved_at" (ISO 8601) diff --git a/prompts/v1/goals.txt b/prompts/v1/goals.txt new file mode 100644 index 0000000..b4df077 --- /dev/null +++ b/prompts/v1/goals.txt @@ -0,0 +1 @@ +- "goals": array of objects with "text" (description) and "status" ("open", "resolved", "abandoned", "in_progress") diff --git a/prompts/v1/postamble.txt b/prompts/v1/postamble.txt new file mode 100644 index 0000000..3fa2a4e --- /dev/null +++ b/prompts/v1/postamble.txt @@ -0,0 +1,7 @@ +Rules: +- Extract what's IN the text. Do not infer or fabricate. +- Use specific details, not general descriptions. +- Output ONLY valid JSON. No markdown fences, no explanation. + +Text: +{{text}} diff --git a/prompts/v1/preamble.txt b/prompts/v1/preamble.txt new file mode 100644 index 0000000..103849f --- /dev/null +++ b/prompts/v1/preamble.txt @@ -0,0 +1,8 @@ +Extract structured data from the following text. + +{{#if categories}}Available categories: {{categories}}{{/if}} +{{#if source_type}}Content type: {{source_type}}{{/if}} +{{#if date}}Date: {{date}}{{/if}} + +Return a JSON object with the following fields: +- "extracted_at": current ISO 8601 timestamp diff --git a/prompts/v1/relation_origin.txt b/prompts/v1/relation_origin.txt new file mode 100644 index 0000000..c45c4ab --- /dev/null +++ b/prompts/v1/relation_origin.txt @@ -0,0 +1 @@ + - include "origin": "explicit" (stated in text), "inferred" (deduced), or "dependent" (reverse edge) diff --git a/prompts/v1/relations.txt b/prompts/v1/relations.txt new file mode 100644 index 0000000..79e65e4 --- /dev/null +++ b/prompts/v1/relations.txt @@ -0,0 +1 @@ + - include "relations": array of {"target": entity ID, "type": relationship type} diff --git a/prompts/v1/sentiment.txt b/prompts/v1/sentiment.txt new file mode 100644 index 0000000..bd27083 --- /dev/null +++ b/prompts/v1/sentiment.txt @@ -0,0 +1 @@ +- "sentiment": overall emotional tone in one word diff --git a/prompts/v1/summary.txt b/prompts/v1/summary.txt new file mode 100644 index 0000000..7fa90c0 --- /dev/null +++ b/prompts/v1/summary.txt @@ -0,0 +1 @@ +- "summary": one sentence, max 200 characters diff --git a/prompts/v1/temporal_classes.txt b/prompts/v1/temporal_classes.txt new file mode 100644 index 0000000..5a98044 --- /dev/null +++ b/prompts/v1/temporal_classes.txt @@ -0,0 +1 @@ + - include "type" ("point", "range", "duration", "unresolved") and "resolved_end" for ranges diff --git a/prompts/v1/temporal_refs.txt b/prompts/v1/temporal_refs.txt new file mode 100644 index 0000000..7d14672 --- /dev/null +++ b/prompts/v1/temporal_refs.txt @@ -0,0 +1 @@ +- "temporal_refs": array with "raw" (as it appeared) and "resolved" (ISO 8601). Resolve relative dates using: {{date}} diff --git a/prompts/v1/themes.txt b/prompts/v1/themes.txt new file mode 100644 index 0000000..ec5f73b --- /dev/null +++ b/prompts/v1/themes.txt @@ -0,0 +1 @@ +- "themes": array of topic strings{{#if categories}} chosen from: {{categories}}{{/if}} diff --git a/tests/python/test_prompt.py b/tests/python/test_prompt.py new file mode 100644 index 0000000..d8ec127 --- /dev/null +++ b/tests/python/test_prompt.py @@ -0,0 +1,365 @@ +"""Tests for SynaptExtraction IL v1 composable prompt system.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages" / "python" / "src")) + +from synapt_extract.prompt import build_extraction_prompt, resolve_capabilities + + +SAMPLE_TEXT = "Please pray for my mom. She had surgery on April 20 and is recovering well." + + +class TestResolveCapabilities: + + def test_explicit_capabilities(self): + result = resolve_capabilities(capabilities=["entities", "goals", "themes"]) + assert set(result) == {"entities", "goals", "themes"} + + def test_profile_minimal(self): + result = resolve_capabilities(profile="minimal") + assert "entities" in result + assert "entity_state" in result + assert "goals" in result + assert "themes" in result + assert "summary" in result + assert "relations" not in result + + def test_profile_standard(self): + result = resolve_capabilities(profile="standard") + assert "entities" in result + assert "entity_context" in result + assert "goal_timing" in result + assert "facts" in result + assert "temporal_refs" in result + assert "sentiment" in result + assert "evidence_anchoring" in result + assert "relations" not in result + + def test_profile_full(self): + result = resolve_capabilities(profile="full") + assert "entity_ids" in result + assert "goal_entity_refs" in result + assert "relations" in result + assert "relation_origin" in result + assert "assertion_signals" in result + assert "temporal_classes" in result + + def test_profile_with_add(self): + result = resolve_capabilities(profile="minimal", add=["relations"]) + assert "entities" in result + assert "relations" in result + assert "entity_ids" in result + + def test_profile_with_remove(self): + result = resolve_capabilities(profile="standard", remove=["sentiment"]) + assert "entities" in result + assert "sentiment" not in result + + def test_profile_with_add_and_remove(self): + result = resolve_capabilities( + profile="standard", + add=["relations"], + remove=["sentiment"], + ) + assert "relations" in result + assert "entity_ids" in result + assert "sentiment" not in result + + def test_unknown_profile_raises(self): + with pytest.raises(ValueError, match="Unknown profile"): + resolve_capabilities(profile="psychic") + + def test_no_capabilities_or_profile_raises(self): + with pytest.raises(ValueError): + resolve_capabilities() + + +class TestDependencyClosure: + + def test_entity_state_adds_entities(self): + result = resolve_capabilities(capabilities=["entity_state"]) + assert "entities" in result + + def test_entity_context_adds_entities(self): + result = resolve_capabilities(capabilities=["entity_context"]) + assert "entities" in result + + def test_entity_ids_adds_entities(self): + result = resolve_capabilities(capabilities=["entity_ids"]) + assert "entities" in result + + def test_goal_timing_adds_goals(self): + result = resolve_capabilities(capabilities=["goal_timing"]) + assert "goals" in result + + def test_goal_entity_refs_adds_goals_and_entity_ids(self): + result = resolve_capabilities(capabilities=["goal_entity_refs"]) + assert "goals" in result + assert "entity_ids" in result + assert "entities" in result + + def test_temporal_classes_adds_temporal_refs(self): + result = resolve_capabilities(capabilities=["temporal_classes"]) + assert "temporal_refs" in result + + def test_relations_adds_entities_and_entity_ids(self): + result = resolve_capabilities(capabilities=["relations"]) + assert "entities" in result + assert "entity_ids" in result + + def test_relation_origin_adds_relations(self): + result = resolve_capabilities(capabilities=["relation_origin"]) + assert "relations" in result + assert "entities" in result + assert "entity_ids" in result + + def test_transitive_closure(self): + result = resolve_capabilities(capabilities=["relation_origin"]) + assert "relation_origin" in result + assert "relations" in result + assert "entity_ids" in result + assert "entities" in result + + +class TestBuildPromptBasics: + + def test_returns_string(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + assert isinstance(result, str) + assert len(result) > 0 + + def test_contains_text(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + assert SAMPLE_TEXT in result + + def test_contains_extracted_at_instruction(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + assert "extracted_at" in result + + def test_contains_json_instruction(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + assert "JSON" in result + + +class TestBuildPromptCapabilities: + + def test_entities_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["entities"]) + assert '"entities"' in result + assert '"name"' in result + assert '"type"' in result + + def test_entity_state_fragment_present(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + capabilities=["entities", "entity_state"], + ) + assert '"state"' in result or "state" in result + + def test_goals_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["goals"]) + assert '"goals"' in result + assert '"status"' in result + + def test_themes_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["themes"]) + assert '"themes"' in result + + def test_summary_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["summary"]) + assert '"summary"' in result + + def test_sentiment_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["sentiment"]) + assert '"sentiment"' in result + + def test_facts_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["facts"]) + assert '"facts"' in result + + def test_temporal_refs_fragment_present(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["temporal_refs"]) + assert '"temporal_refs"' in result + + def test_relations_fragment_present(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + capabilities=["entities", "entity_ids", "relations"], + ) + assert '"relations"' in result + + def test_assertion_signals_fragment_present(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + capabilities=["entities", "assertion_signals"], + ) + assert '"signals"' in result or "signals" in result + + def test_evidence_anchoring_fragment_present(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + capabilities=["entities", "evidence_anchoring"], + ) + assert '"source"' in result or "source" in result + + def test_absent_capability_not_in_prompt(self): + result = build_extraction_prompt(SAMPLE_TEXT, capabilities=["entities"]) + assert '"relations"' not in result + assert '"sentiment"' not in result + + +class TestBuildPromptOptions: + + def test_categories_included(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + profile="minimal", + categories=["Health", "Family"], + ) + assert "Health" in result + assert "Family" in result + + def test_source_type_included(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + profile="minimal", + source_type="prayer", + ) + assert "prayer" in result + + def test_date_included(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + profile="minimal", + date="2026-04-25", + ) + assert "2026-04-25" in result + + def test_date_used_for_temporal_resolution(self): + result = build_extraction_prompt( + SAMPLE_TEXT, + capabilities=["temporal_refs"], + date="2026-04-25", + ) + assert "2026-04-25" in result + + +class TestBuildPromptProfiles: + + def test_minimal_does_not_include_relations(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + assert '"relations"' not in result + + def test_standard_includes_facts(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="standard") + assert '"facts"' in result + + def test_full_includes_relations(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="full") + assert '"relations"' in result + + def test_profile_with_capabilities_raises(self): + with pytest.raises(ValueError): + build_extraction_prompt( + SAMPLE_TEXT, + profile="minimal", + capabilities=["entities"], + ) + + +class TestBuildPromptCompositionOrder: + + def test_preamble_before_fragments(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="full") + extract_idx = result.index("Extract structured data") + entities_idx = result.index('"entities"') + assert extract_idx < entities_idx + + def test_text_at_end(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="minimal") + text_idx = result.rindex(SAMPLE_TEXT) + rules_idx = result.index("Rules:") + assert rules_idx < text_idx + + def test_primary_before_modifiers(self): + result = build_extraction_prompt(SAMPLE_TEXT, profile="full") + entities_idx = result.index('"entities"') + entity_ids_section = result.find('"id"') + assert entities_idx < entity_ids_section + + +class TestPromptFragmentFiles: + + def test_all_fragment_files_exist(self): + prompts_dir = Path(__file__).resolve().parents[2] / "prompts" / "v1" + expected = [ + "preamble.txt", "postamble.txt", + "entities.txt", "entity_state.txt", "entity_context.txt", "entity_ids.txt", + "goals.txt", "goal_timing.txt", "goal_entity_refs.txt", + "themes.txt", "summary.txt", "sentiment.txt", "facts.txt", + "temporal_refs.txt", "temporal_classes.txt", + "relations.txt", "relation_origin.txt", + "assertion_signals.txt", "evidence_anchoring.txt", + ] + for name in expected: + assert (prompts_dir / name).exists(), f"Missing fragment: {name}" + + def test_all_fragment_files_non_empty(self): + prompts_dir = Path(__file__).resolve().parents[2] / "prompts" / "v1" + for txt_file in prompts_dir.glob("*.txt"): + content = txt_file.read_text().strip() + assert len(content) > 0, f"Empty fragment: {txt_file.name}" + + +class TestProfileFiles: + + def test_all_profile_files_exist(self): + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + for name in ["minimal.json", "standard.json", "full.json"]: + assert (profiles_dir / name).exists(), f"Missing profile: {name}" + + def test_profile_files_are_valid_json(self): + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + for name in ["minimal.json", "standard.json", "full.json"]: + content = (profiles_dir / name).read_text() + data = json.loads(content) + assert "capabilities" in data, f"{name} missing capabilities key" + assert isinstance(data["capabilities"], list) + + def test_minimal_profile_contents(self): + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + data = json.loads((profiles_dir / "minimal.json").read_text()) + caps = set(data["capabilities"]) + assert caps == {"entities", "entity_state", "goals", "themes", "summary"} + + def test_standard_profile_contents(self): + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + data = json.loads((profiles_dir / "standard.json").read_text()) + caps = set(data["capabilities"]) + assert "entities" in caps + assert "entity_context" in caps + assert "goal_timing" in caps + assert "facts" in caps + assert "temporal_refs" in caps + assert "sentiment" in caps + assert "evidence_anchoring" in caps + + def test_full_profile_is_superset_of_standard(self): + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + standard = set(json.loads((profiles_dir / "standard.json").read_text())["capabilities"]) + full = set(json.loads((profiles_dir / "full.json").read_text())["capabilities"]) + assert standard.issubset(full) + + def test_full_profile_includes_all_capabilities(self): + from synapt_extract.schema import EXTRACTION_CAPABILITIES + profiles_dir = Path(__file__).resolve().parents[2] / "prompts" / "profiles" + data = json.loads((profiles_dir / "full.json").read_text()) + caps = set(data["capabilities"]) + assert caps == EXTRACTION_CAPABILITIES From bfd64d19163766a71bba74cbbc3dd66eb640b2a9 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 09:53:21 -0500 Subject: [PATCH 02/12] fix: harden prompt system against adversarial inputs Address Atlas review findings on extract#2: - Validate capability names upfront; unknown capabilities raise ValueError before any file IO (no more raw FileNotFoundError on bogus.txt) - Fix template double-escaping: caller-controlled values (categories, source_type) are no longer recursively expanded through the template engine. Values are treated as opaque strings. - Reject empty capability sets explicitly - Reject modifier-only capability sets (assertion_signals/evidence_anchoring without entities, goals, or facts) - Update PROMPTS_DIR to resolve from installed package path first, falling back to repo root for development Co-Authored-By: Claude Opus 4.6 --- packages/python/src/synapt_extract/prompt.py | 30 ++++++++- packages/ts/src/prompt.ts | 44 ++++++++++++- tests/python/test_prompt.py | 65 ++++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/packages/python/src/synapt_extract/prompt.py b/packages/python/src/synapt_extract/prompt.py index c0dc8d2..cdcacb4 100644 --- a/packages/python/src/synapt_extract/prompt.py +++ b/packages/python/src/synapt_extract/prompt.py @@ -7,7 +7,11 @@ from pathlib import Path from typing import Any -PROMPTS_DIR = Path(__file__).resolve().parents[4] / "prompts" +from synapt_extract.schema import EXTRACTION_CAPABILITIES + +_INSTALLED_PROMPTS = Path(__file__).resolve().parent / "prompts" +_REPO_PROMPTS = Path(__file__).resolve().parents[4] / "prompts" +PROMPTS_DIR = _INSTALLED_PROMPTS if _INSTALLED_PROMPTS.is_dir() else _REPO_PROMPTS CAPABILITY_DEPS: dict[str, list[str]] = { "entity_state": ["entities"], @@ -55,7 +59,7 @@ def replace_if(match: re.Match) -> str: var = match.group(1) body = match.group(2) if context.get(var): - return _render_vars(body, context) + return body return "" result = re.sub(r"\{\{#if (\w+)\}\}(.*?)\{\{/if\}\}", replace_if, template, flags=re.DOTALL) @@ -73,6 +77,16 @@ def replace_var(match: re.Match) -> str: return re.sub(r"\{\{(\w+)\}\}", replace_var, template) +BASE_CAPABILITIES = frozenset(["entities", "goals", "facts"]) +MODIFIER_ONLY_CAPABILITIES = frozenset(["assertion_signals", "evidence_anchoring"]) + + +def _validate_capability_names(caps: set[str], source: str) -> None: + unknown = caps - EXTRACTION_CAPABILITIES + if unknown: + raise ValueError(f"Unknown {source}: {', '.join(sorted(unknown))}") + + def resolve_capabilities( *, capabilities: list[str] | None = None, @@ -84,11 +98,13 @@ def resolve_capabilities( raise ValueError("Either capabilities or profile must be provided") if capabilities is not None: + _validate_capability_names(set(capabilities), "capabilities") caps = set(capabilities) else: caps = set(_load_profile(profile)) if add: + _validate_capability_names(set(add), "capabilities in add") caps.update(add) if remove: caps -= set(remove) @@ -102,6 +118,16 @@ def resolve_capabilities( caps.add(dep) changed = True + if not caps: + raise ValueError("Resolved capability set is empty") + + modifiers_present = caps & MODIFIER_ONLY_CAPABILITIES + if modifiers_present and not (caps & BASE_CAPABILITIES): + raise ValueError( + f"Modifier capabilities {sorted(modifiers_present)} require at least one " + f"base capability ({', '.join(sorted(BASE_CAPABILITIES))})" + ) + return sorted(caps, key=lambda c: CANONICAL_ORDER.index(c) if c in CANONICAL_ORDER else len(CANONICAL_ORDER)) diff --git a/packages/ts/src/prompt.ts b/packages/ts/src/prompt.ts index d2fea74..e91de43 100644 --- a/packages/ts/src/prompt.ts +++ b/packages/ts/src/prompt.ts @@ -1,11 +1,25 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtractionCapability } from "./schema.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PROMPTS_DIR = resolve(__dirname, "..", "..", "..", "prompts"); +const _installedPrompts = resolve(__dirname, "..", "prompts"); +const _repoPrompts = resolve(__dirname, "..", "..", "..", "prompts"); +const PROMPTS_DIR = existsSync(_installedPrompts) ? _installedPrompts : _repoPrompts; + +const VALID_CAPABILITIES: Set = new Set([ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", "facts", + "temporal_refs", "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring", +]); + +const BASE_CAPABILITIES: Set = new Set(["entities", "goals", "facts"]); +const MODIFIER_ONLY: Set = new Set(["assertion_signals", "evidence_anchoring"]); export interface PromptOptions { capabilities?: ExtractionCapability[]; @@ -59,7 +73,7 @@ function renderTemplate(template: string, ctx: Record): string let result = template.replace( /\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_match, varName: string, body: string) => { - return ctx[varName] ? renderVars(body, ctx) : ""; + return ctx[varName] ? body : ""; }, ); return renderVars(result, ctx); @@ -73,10 +87,21 @@ function renderVars(template: string, ctx: Record): string { }); } +function validateCapabilityNames(caps: Iterable, source: string): void { + const unknown: string[] = []; + for (const c of caps) { + if (!VALID_CAPABILITIES.has(c)) unknown.push(c); + } + if (unknown.length > 0) { + throw new Error(`Unknown ${source}: ${unknown.sort().join(", ")}`); + } +} + export function resolveCapabilities(options: Pick): ExtractionCapability[] { let caps: Set; if (options.capabilities != null) { + validateCapabilityNames(options.capabilities, "capabilities"); caps = new Set(options.capabilities); } else if (options.profile != null) { caps = new Set(loadProfile(options.profile)); @@ -85,6 +110,7 @@ export function resolveCapabilities(options: Pick MODIFIER_ONLY.has(c)); + if (modifiers.length > 0 && ![...caps].some((c) => BASE_CAPABILITIES.has(c))) { + throw new Error( + `Modifier capabilities [${modifiers.sort().join(", ")}] require at least one ` + + `base capability (${[...BASE_CAPABILITIES].sort().join(", ")})` + ); + } + return [...caps].sort((a, b) => { const ai = CANONICAL_ORDER.indexOf(a); const bi = CANONICAL_ORDER.indexOf(b); diff --git a/tests/python/test_prompt.py b/tests/python/test_prompt.py index d8ec127..8813b18 100644 --- a/tests/python/test_prompt.py +++ b/tests/python/test_prompt.py @@ -81,6 +81,71 @@ def test_no_capabilities_or_profile_raises(self): resolve_capabilities() +class TestUnknownCapabilityRejection: + + def test_unknown_capability_raises(self): + with pytest.raises(ValueError, match="Unknown capabilities"): + resolve_capabilities(capabilities=["bogus"]) + + def test_unknown_in_add_raises(self): + with pytest.raises(ValueError, match="Unknown capabilities"): + resolve_capabilities(capabilities=["entities"], add=["psychic"]) + + def test_unknown_does_not_touch_filesystem(self): + with pytest.raises(ValueError): + resolve_capabilities(capabilities=["bogus"]) + + def test_multiple_unknown_lists_all(self): + with pytest.raises(ValueError, match="bogus") as exc_info: + resolve_capabilities(capabilities=["bogus", "fake", "entities"]) + assert "fake" in str(exc_info.value) + + +class TestEmptyAndModifierOnlySets: + + def test_empty_after_remove_raises(self): + with pytest.raises(ValueError, match="empty"): + resolve_capabilities(capabilities=["entities"], remove=["entities"]) + + def test_modifier_only_assertion_signals_raises(self): + with pytest.raises(ValueError, match="base capability"): + resolve_capabilities(capabilities=["assertion_signals"]) + + def test_modifier_only_evidence_anchoring_raises(self): + with pytest.raises(ValueError, match="base capability"): + resolve_capabilities(capabilities=["evidence_anchoring"]) + + def test_modifier_with_base_accepted(self): + result = resolve_capabilities(capabilities=["entities", "assertion_signals"]) + assert "assertion_signals" in result + assert "entities" in result + + def test_modifier_with_facts_accepted(self): + result = resolve_capabilities(capabilities=["facts", "evidence_anchoring"]) + assert "evidence_anchoring" in result + assert "facts" in result + + +class TestTemplateInjectionPrevention: + + def test_categories_not_double_expanded(self): + result = build_extraction_prompt( + "hello", + capabilities=["entities"], + categories=["A{{text}}B"], + ) + assert "A{{text}}B" in result + assert "AhelloB" not in result + + def test_source_type_not_expanded(self): + result = build_extraction_prompt( + "hello", + capabilities=["entities"], + source_type="{{date}}", + ) + assert "{{date}}" in result + + class TestDependencyClosure: def test_entity_state_adds_entities(self): From ea6c274e85ccc42bf3bd91cc8f1b44c44450cd28 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 09:18:32 -0500 Subject: [PATCH 03/12] feat: publish prep for npm + PyPI (recall#795) - CI workflow: Python 3.10-3.13 test matrix + TypeScript type-check - npm publish workflow: triggered on GitHub release, provenance enabled - PyPI publish workflow: trusted publishing via gh-action-pypi-publish - package.json: publishConfig with public access and provenance - pyproject.toml: classifiers, schema URL, documentation URL - README: package overview, quick start (TS + Python), pipeline docs Trusted publishing setup (npm token, PyPI environment) is a manual step. Ref synapt-dev/recall#795 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 39 ++++++++++ .github/workflows/publish-npm.yml | 31 ++++++++ .github/workflows/publish-pypi.yml | 31 ++++++++ README.md | 117 +++++++++++++++++++++++++++++ packages/python/pyproject.toml | 18 ++++- packages/ts/package.json | 4 + 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish-npm.yml create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d6b5348 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test-python: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - run: pip install pytest + + - run: pytest tests/python/ -v + + check-typescript: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/ts + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - run: npm ci + + - run: npx tsc --noEmit diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..70b12eb --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,31 @@ +name: Publish @synapt-dev/extract to npm + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/ts + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - run: npm ci + + - run: npm run build + + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..dd0dbeb --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +name: Publish synapt-extract to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + defaults: + run: + working-directory: packages/python + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install build + + - run: python -m build + + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/python/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..48318a5 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# @synapt-dev/extract + +SynaptExtraction is the intermediate language (IL) for [synapt](https://synapt.dev)'s product stack. It is the universal exchange format between text extraction and intelligence operations. + +``` +Any text + Any LLM -> SynaptExtraction (IL) -> @synapt/memory (intelligence) +``` + +This repo contains the v1 schema, types, validators, finalization pipeline, and composable prompt system in both TypeScript and Python. + +## Packages + +| Package | Registry | Install | +|---------|----------|---------| +| `@synapt-dev/extract` | npm | `npm install @synapt-dev/extract` | +| `synapt-extract` | PyPI | `pip install synapt-extract` | + +## Quick start + +### TypeScript + +```typescript +import { + buildExtractionPrompt, + finalizeExtraction, + validateExtraction, +} from "@synapt-dev/extract"; + +// 1. Build a prompt for your LLM +const prompt = buildExtractionPrompt(text, { + profile: "standard", + categories: ["Health", "Family"], +}); + +// 2. Send to any LLM, parse JSON response +const llmOutput = JSON.parse(await llm.complete(prompt)); + +// 3. Finalize: inject client context, normalize, validate +const result = finalizeExtraction(llmOutput, { + produced_by: "openai://gpt-4o-mini", + user_id: userId, + kind: "conversa/prayer", +}); + +console.log(result.extraction); // Complete SynaptExtraction +console.log(result.validation); // { valid: true, errors: [] } +``` + +### Python + +```python +from synapt_extract import ( + build_extraction_prompt, + finalize_extraction, + FinalizeContext, +) + +# 1. Build a prompt +prompt = build_extraction_prompt(text, profile="standard") + +# 2. Send to any LLM, parse JSON response +llm_output = json.loads(llm.complete(prompt)) + +# 3. Finalize +result = finalize_extraction(llm_output, FinalizeContext( + produced_by="openai://gpt-4o-mini", + user_id=user_id, + kind="conversa/prayer", +)) + +assert result.validation.valid +``` + +## Three-stage pipeline + +SynaptExtraction documents are assembled in three stages: + +1. **Stage 1 (LLM)**: The LLM extracts content fields (entities, goals, themes, etc.) from text +2. **Stage 2 (Client)**: Your application injects context the LLM can't know (produced_by, user_id, embeddings, extensions) +3. **Stage 3 (Library)**: `finalizeExtraction()` normalizes the document (version injection, capability detection, sub-schema versioning, validation) + +## Prompt profiles + +| Profile | Model class | Capabilities | +|---------|------------|--------------| +| `minimal` | 3B-7B local | entities, entity_state, goals, themes, summary | +| `standard` | GPT-4o-mini, Haiku | + entity_context, goal_timing, facts, temporal_refs, sentiment, evidence_anchoring | +| `full` | GPT-4o, Sonnet, Opus | + entity_ids, goal_entity_refs, relations, relation_origin, assertion_signals, temporal_classes | + +## JSON Schema + +The canonical schema is hosted at: + +``` +https://synapt.dev/schemas/extraction/v1.json +``` + +Sub-schemas: `source-ref/v1.json`, `embedding/v1.json`, `assertion-signals/v1.json`, `temporal-ref/v1.json`. + +## Repo structure + +``` +extract/ + packages/ + ts/ # @synapt-dev/extract (TypeScript, npm) + python/ # synapt-extract (Python, PyPI) + schemas/ # JSON Schema files (language-agnostic) + prompts/ + v1/ # Capability prompt fragments + profiles/ # Profile definitions (minimal, standard, full) + tests/ + python/ # Python test suite +``` + +## License + +MIT diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 436f1db..7e4c7d7 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -5,17 +5,31 @@ build-backend = "setuptools.build_meta" [project] name = "synapt-extract" version = "0.1.0" -description = "SynaptExtraction IL v1 — schema, validation, and finalization" -readme = "README.md" +description = "SynaptExtraction IL v1 -- schema, validation, and finalization" +readme = "../../README.md" license = "MIT" requires-python = ">=3.10" authors = [ {name = "Layne Penney"}, ] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", +] [project.urls] Homepage = "https://synapt.dev" Repository = "https://github.com/synapt-dev/extract" +Documentation = "https://synapt.dev/docs/extract" +Schema = "https://synapt.dev/schemas/extraction/v1.json" [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/ts/package.json b/packages/ts/package.json index d1265d9..06887d2 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -11,6 +11,10 @@ "types": "./dist/index.d.ts" } }, + "publishConfig": { + "access": "public", + "provenance": true + }, "files": [ "dist", "src" From c0e2026c052f949c9f582a24ed9e39c2c216d775 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 09:56:59 -0500 Subject: [PATCH 04/12] fix: publish blockers (readme, prompt assets, CI build, SHA pinning) Address review findings on extract#3: - Fix pyproject.toml readme path: create packages/python/README.md instead of referencing ../../README.md which breaks setuptools - Remove PEP 639-incompatible license classifier - Add prompt asset bundling: npm prepack copies prompts/ into package, Python pyproject.toml declares package-data for prompts/** - Add build-python CI job to catch build failures before publish - SHA-pin all GitHub Actions (actions/checkout, setup-node, setup-python, gh-action-pypi-publish) for supply-chain hardening - Add .gitignore entries for build-time prompt copies npm still uses NPM_TOKEN for auth; full OIDC trusted publishing requires linking the package on npmjs.com (Layne setup). PyPI uses OIDC via gh-action-pypi-publish (no secrets needed, already configured). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 26 +++++++++++--- .github/workflows/publish-npm.yml | 4 +-- .github/workflows/publish-pypi.yml | 8 +++-- .gitignore | 3 ++ packages/python/README.md | 56 ++++++++++++++++++++++++++++++ packages/python/pyproject.toml | 6 ++-- packages/ts/package.json | 6 ++-- 7 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 packages/python/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6b5348..0da395f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -22,15 +22,33 @@ jobs: - run: pytest tests/python/ -v + build-python: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/python + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - run: pip install build + + - run: test -d ../../prompts && cp -r ../../prompts src/synapt_extract/prompts || true + + - run: python -m build + check-typescript: runs-on: ubuntu-latest defaults: run: working-directory: packages/ts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 70b12eb..196483a 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -15,9 +15,9 @@ jobs: run: working-directory: packages/ts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index dd0dbeb..8ff99a0 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -16,16 +16,18 @@ jobs: run: working-directory: packages/python steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - run: pip install build + - run: test -d ../../prompts && cp -r ../../prompts src/synapt_extract/prompts || true + - run: python -m build - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b54f3568f20e4e024f6eb07a # release/v1 with: packages-dir: packages/python/dist/ diff --git a/.gitignore b/.gitignore index 860621d..a39138e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ dist/ *.js.map !packages/ts/src/**/*.ts +packages/ts/prompts/ +packages/python/src/synapt_extract/prompts/ + __pycache__/ *.pyc *.egg-info/ diff --git a/packages/python/README.md b/packages/python/README.md new file mode 100644 index 0000000..807321c --- /dev/null +++ b/packages/python/README.md @@ -0,0 +1,56 @@ +# synapt-extract + +SynaptExtraction is the intermediate language (IL) for [synapt](https://synapt.dev)'s product stack. It is the universal exchange format between text extraction and intelligence operations. + +``` +Any text + Any LLM -> SynaptExtraction (IL) -> @synapt/memory (intelligence) +``` + +## Install + +```bash +pip install synapt-extract +``` + +## Quick start + +```python +from synapt_extract import ( + build_extraction_prompt, + finalize_extraction, + FinalizeContext, +) + +# 1. Build a prompt +prompt = build_extraction_prompt(text, profile="standard") + +# 2. Send to any LLM, parse JSON response +llm_output = json.loads(llm.complete(prompt)) + +# 3. Finalize +result = finalize_extraction(llm_output, FinalizeContext( + produced_by="openai://gpt-4o-mini", + user_id=user_id, + kind="conversa/prayer", +)) + +assert result.validation.valid +``` + +## Prompt profiles + +| Profile | Model class | Capabilities | +|---------|------------|--------------| +| `minimal` | 3B-7B local | entities, entity_state, goals, themes, summary | +| `standard` | GPT-4o-mini, Haiku | + entity_context, goal_timing, facts, temporal_refs, sentiment, evidence_anchoring | +| `full` | GPT-4o, Sonnet, Opus | + entity_ids, goal_entity_refs, relations, relation_origin, assertion_signals, temporal_classes | + +## Links + +- [Repository](https://github.com/synapt-dev/extract) +- [JSON Schema](https://synapt.dev/schemas/extraction/v1.json) +- [TypeScript package](https://www.npmjs.com/package/@synapt-dev/extract) + +## License + +MIT diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 7e4c7d7..6858b17 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "synapt-extract" version = "0.1.0" description = "SynaptExtraction IL v1 -- schema, validation, and finalization" -readme = "../../README.md" +readme = "README.md" license = "MIT" requires-python = ">=3.10" authors = [ @@ -15,7 +15,6 @@ authors = [ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -33,3 +32,6 @@ Schema = "https://synapt.dev/schemas/extraction/v1.json" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools.package-data] +synapt_extract = ["prompts/**/*.txt", "prompts/**/*.json"] diff --git a/packages/ts/package.json b/packages/ts/package.json index 06887d2..62eaaf6 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -17,11 +17,13 @@ }, "files": [ "dist", - "src" + "src", + "prompts" ], "scripts": { "build": "tsc", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "prepack": "test -d ../../prompts && cp -r ../../prompts ./prompts || true" }, "keywords": [ "synapt", From 277674837330ece782fcfc7bc9767afc937dfc09 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 10:20:06 -0500 Subject: [PATCH 05/12] fix: rename schemas/extraction/ to schemas/extract/ per locked spec Canonical path is synapt.dev/schemas/extract/v1.json, not extraction/. Updated directory name, $id field, and all references in README, Python README, pyproject.toml, and test fixtures. Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- packages/python/README.md | 2 +- packages/python/pyproject.toml | 2 +- schemas/{extraction => extract}/v1.json | 2 +- tests/python/test_validate.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename schemas/{extraction => extract}/v1.json (99%) diff --git a/README.md b/README.md index 48318a5..8b66f7f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ SynaptExtraction documents are assembled in three stages: The canonical schema is hosted at: ``` -https://synapt.dev/schemas/extraction/v1.json +https://synapt.dev/schemas/extract/v1.json ``` Sub-schemas: `source-ref/v1.json`, `embedding/v1.json`, `assertion-signals/v1.json`, `temporal-ref/v1.json`. diff --git a/packages/python/README.md b/packages/python/README.md index 807321c..58dc9fb 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -48,7 +48,7 @@ assert result.validation.valid ## Links - [Repository](https://github.com/synapt-dev/extract) -- [JSON Schema](https://synapt.dev/schemas/extraction/v1.json) +- [JSON Schema](https://synapt.dev/schemas/extract/v1.json) - [TypeScript package](https://www.npmjs.com/package/@synapt-dev/extract) ## License diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 6858b17..e96038f 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ Homepage = "https://synapt.dev" Repository = "https://github.com/synapt-dev/extract" Documentation = "https://synapt.dev/docs/extract" -Schema = "https://synapt.dev/schemas/extraction/v1.json" +Schema = "https://synapt.dev/schemas/extract/v1.json" [tool.setuptools.packages.find] where = ["src"] diff --git a/schemas/extraction/v1.json b/schemas/extract/v1.json similarity index 99% rename from schemas/extraction/v1.json rename to schemas/extract/v1.json index a294e6b..67ffb63 100644 --- a/schemas/extraction/v1.json +++ b/schemas/extract/v1.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://synapt.dev/schemas/extraction/v1.json", + "$id": "https://synapt.dev/schemas/extract/v1.json", "title": "SynaptExtraction", "description": "SynaptExtraction IL v1 — universal exchange format between text extraction and intelligence operations.", "type": "object", diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index b8e3871..9aa1368 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -717,7 +717,7 @@ def test_all_schema_files_are_valid_json(self): assert "$id" in parsed, f"{schema_file.name} missing $id" def test_extraction_schema_references_sub_schemas(self): - schema_path = Path(__file__).resolve().parents[2] / "schemas" / "extraction" / "v1.json" + schema_path = Path(__file__).resolve().parents[2] / "schemas" / "extract" / "v1.json" schema = json.loads(schema_path.read_text()) schema_str = json.dumps(schema) assert "source-ref/v1.json" in schema_str @@ -726,7 +726,7 @@ def test_extraction_schema_references_sub_schemas(self): assert "temporal-ref/v1.json" in schema_str def test_extraction_schema_required_fields(self): - schema_path = Path(__file__).resolve().parents[2] / "schemas" / "extraction" / "v1.json" + schema_path = Path(__file__).resolve().parents[2] / "schemas" / "extract" / "v1.json" schema = json.loads(schema_path.read_text()) required = schema["required"] assert "version" in required From 8538d4765bcc210a519dc952f36aad89e338275a Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 10:45:10 -0500 Subject: [PATCH 06/12] fix: switch npm publish to OIDC trusted publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove NPM_TOKEN secret now that trusted publishing is configured on npmjs.com (synapt-dev/extract → publish-npm.yml). Subsequent releases authenticate via GitHub OIDC token exchange with Sigstore provenance attestations. No long-lived secrets needed. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/publish-npm.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 196483a..d8ebf14 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -27,5 +27,3 @@ jobs: - run: npm run build - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From f6681abadd9a2fc544527aa69a83c58994beb000 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:12:37 -0500 Subject: [PATCH 07/12] fix: close schema-validator parity gaps (3 findings) 1. TS profile validation: loadProfile() now checks file existence before readFileSync, throwing clean "Unknown profile" error instead of raw ENOENT. Matches Python behavior. 2. produced_by schema tightening: JSON Schema now includes pattern constraint matching the URI format validators already enforce. Third-party JSON Schema validators will now agree with our package validators. 3. extracted_at date-time strictness: both validators now require full ISO 8601 date-time (with T component), rejecting date-only strings. Matches the schema's "format": "date-time" constraint. Closes the drift where third-party JSON Schema validators would give different verdicts than @synapt-dev/extract validators. Co-Authored-By: Claude Opus 4.6 --- packages/python/src/synapt_extract/validate.py | 15 ++++++++++++--- packages/ts/src/prompt.ts | 3 +++ packages/ts/src/validate.ts | 11 ++++++++--- schemas/extract/v1.json | 1 + tests/python/test_validate.py | 15 +++++++++++++-- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/python/src/synapt_extract/validate.py b/packages/python/src/synapt_extract/validate.py index 0da8700..673adb2 100644 --- a/packages/python/src/synapt_extract/validate.py +++ b/packages/python/src/synapt_extract/validate.py @@ -18,6 +18,11 @@ r"(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?" r"(?:Z|[+\-]\d{2}:?\d{2})?)?$" ) +_ISO_DATETIME_STRICT_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}" + r"T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?" + r"(?:Z|[+\-]\d{2}:?\d{2})?$" +) @dataclass @@ -40,6 +45,10 @@ def _is_iso_datetime(s: str) -> bool: return bool(_ISO_DATE_RE.match(s)) +def _is_iso_datetime_strict(s: str) -> bool: + return bool(_ISO_DATETIME_STRICT_RE.match(s)) + + def _is_namespaced(s: str) -> bool: return bool(_NAMESPACED_RE.match(s)) @@ -222,9 +231,9 @@ def validate_extraction(obj: Any) -> ValidationResult: extracted_at = obj.get("extracted_at") if not isinstance(extracted_at, str): - errors.append(ValidationError("extracted_at", "required string (ISO 8601)")) - elif not _is_iso_datetime(extracted_at): - errors.append(ValidationError("extracted_at", "must be a valid ISO 8601 date/datetime")) + errors.append(ValidationError("extracted_at", "required string (ISO 8601 date-time)")) + elif not _is_iso_datetime_strict(extracted_at): + errors.append(ValidationError("extracted_at", "must be a valid ISO 8601 date-time (e.g. 2026-04-26T12:00:00Z)")) produced_by = obj.get("produced_by") if not isinstance(produced_by, str): diff --git a/packages/ts/src/prompt.ts b/packages/ts/src/prompt.ts index e91de43..8b3384b 100644 --- a/packages/ts/src/prompt.ts +++ b/packages/ts/src/prompt.ts @@ -60,6 +60,9 @@ const CAPABILITY_RULES: Partial> = { function loadProfile(name: string): ExtractionCapability[] { const path = resolve(PROMPTS_DIR, "profiles", `${name}.json`); + if (!existsSync(path)) { + throw new Error(`Unknown profile: ${name}`); + } const data = JSON.parse(readFileSync(path, "utf-8")) as { capabilities: ExtractionCapability[] }; return data.capabilities; } diff --git a/packages/ts/src/validate.ts b/packages/ts/src/validate.ts index 166275c..5909bf3 100644 --- a/packages/ts/src/validate.ts +++ b/packages/ts/src/validate.ts @@ -30,6 +30,7 @@ const VALID_TEMPORAL_TYPES: Set = new Set([ const URI_RE = /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\/\S+$/; const NAMESPACED_RE = /^[a-zA-Z0-9_\-]+\/[a-zA-Z0-9_\-]+$/; const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+\-]\d{2}:?\d{2})?)?$/; +const ISO_DATETIME_STRICT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+\-]\d{2}:?\d{2})?$/; function isUri(s: string): boolean { return URI_RE.test(s); @@ -39,6 +40,10 @@ function isIsoDatetime(s: string): boolean { return ISO_DATE_RE.test(s); } +function isIsoDatetimeStrict(s: string): boolean { + return ISO_DATETIME_STRICT_RE.test(s); +} + function isNamespaced(s: string): boolean { return NAMESPACED_RE.test(s); } @@ -270,9 +275,9 @@ export function validateExtraction(obj: unknown): ValidationResult { } if (typeof doc.extracted_at !== "string") { - errors.push({ path: "extracted_at", message: "required string (ISO 8601)" }); - } else if (!isIsoDatetime(doc.extracted_at)) { - errors.push({ path: "extracted_at", message: "must be a valid ISO 8601 date/datetime" }); + errors.push({ path: "extracted_at", message: "required string (ISO 8601 date-time)" }); + } else if (!isIsoDatetimeStrict(doc.extracted_at)) { + errors.push({ path: "extracted_at", message: "must be a valid ISO 8601 date-time (e.g. 2026-04-26T12:00:00Z)" }); } if (typeof doc.produced_by !== "string") { diff --git a/schemas/extract/v1.json b/schemas/extract/v1.json index 67ffb63..80da9ba 100644 --- a/schemas/extract/v1.json +++ b/schemas/extract/v1.json @@ -28,6 +28,7 @@ }, "produced_by": { "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z0-9+.\\-]*://\\S+$", "description": "Model that produced the Stage 1 extraction (URI format: \"provider://model-name\")." }, "kind": { diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index 9aa1368..8e8a901 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -381,16 +381,27 @@ def test_extracted_at_bad_timestamp(self): assert not result.valid assert any("extracted_at" in e.path for e in result.errors) - def test_extracted_at_valid_date(self): + def test_extracted_at_date_only_rejected(self): doc = _minimal_extraction(extracted_at="2026-04-26") result = validate_extraction(doc) - assert result.valid + assert not result.valid + assert any("extracted_at" in e.path for e in result.errors) def test_extracted_at_valid_datetime(self): doc = _minimal_extraction(extracted_at="2026-04-26T10:30:00Z") result = validate_extraction(doc) assert result.valid + def test_extracted_at_valid_datetime_with_offset(self): + doc = _minimal_extraction(extracted_at="2026-04-26T10:30:00+05:30") + result = validate_extraction(doc) + assert result.valid + + def test_extracted_at_valid_datetime_no_seconds(self): + doc = _minimal_extraction(extracted_at="2026-04-26T10:30Z") + result = validate_extraction(doc) + assert result.valid + def test_goal_stated_at_bad(self): doc = _minimal_extraction(goals=[{ "text": "Recovery", From 6c45a3d5715701ccf928604247756796ebe9c653 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:33:20 -0500 Subject: [PATCH 08/12] fix: tighten JSON schemas to match validator semantics Main schema (extract/v1.json): - extracted_at: add pattern alongside format (belt-and-suspenders) - kind: add namespaced pattern - summary: add minLength: 1 - themes items: add minLength: 1 - capabilities items: add enum with all 17 valid capabilities - extensions: add propertyNames pattern for namespacing - Entity name/type: add minLength: 1 - Goal text: add minLength: 1 - Fact text: add minLength: 1 - Relation target/type: add minLength: 1 Sub-schemas: - source-ref: add minProperties: 2 (reject version-only wrappers) - assertion-signals: add minProperties: 2 - embedding model: add URI pattern - embedding computed_at: add pattern alongside format - temporal-ref raw: add minLength: 1 These close the "schema too loose" class from Atlas's drift audit. Co-Authored-By: Claude Opus 4.6 --- schemas/assertion-signals/v1.json | 1 + schemas/embedding/v1.json | 2 ++ schemas/extract/v1.json | 26 ++++++++++++++++++++++++-- schemas/source-ref/v1.json | 1 + schemas/temporal-ref/v1.json | 1 + 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/schemas/assertion-signals/v1.json b/schemas/assertion-signals/v1.json index 42ffd0e..3ed9900 100644 --- a/schemas/assertion-signals/v1.json +++ b/schemas/assertion-signals/v1.json @@ -29,5 +29,6 @@ } }, "required": ["version"], + "minProperties": 2, "additionalProperties": false } diff --git a/schemas/embedding/v1.json b/schemas/embedding/v1.json index a6b0b86..196e6e6 100644 --- a/schemas/embedding/v1.json +++ b/schemas/embedding/v1.json @@ -16,6 +16,7 @@ }, "model": { "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z0-9+.\\-]*://\\S+$", "description": "Model that produced this vector (URI format: \"provider://model-name\")." }, "input": { @@ -34,6 +35,7 @@ "computed_at": { "type": "string", "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(?::\\d{2})?(?:\\.\\d+)?(?:Z|[+\\-]\\d{2}:?\\d{2})?$", "description": "When this embedding was computed (ISO 8601)." } }, diff --git a/schemas/extract/v1.json b/schemas/extract/v1.json index 80da9ba..60c045c 100644 --- a/schemas/extract/v1.json +++ b/schemas/extract/v1.json @@ -12,6 +12,7 @@ "extracted_at": { "type": "string", "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(?::\\d{2})?(?:\\.\\d+)?(?:Z|[+\\-]\\d{2}:?\\d{2})?$", "description": "When the extraction was performed (ISO 8601)." }, "source_id": { @@ -33,6 +34,7 @@ }, "kind": { "type": "string", + "pattern": "^[a-zA-Z0-9_\\-]+/[a-zA-Z0-9_\\-]+$", "description": "Primary extension. Identifies the dominant domain context. Format: \"vendor/kind\"." }, "entities": { @@ -47,7 +49,7 @@ }, "themes": { "type": "array", - "items": { "type": "string" }, + "items": { "type": "string", "minLength": 1 }, "description": "Topic strings." }, "sentiment": { @@ -56,6 +58,7 @@ }, "summary": { "type": "string", + "minLength": 1, "description": "One-sentence summary, max 200 characters." }, "facts": { @@ -70,7 +73,17 @@ }, "capabilities": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string", + "enum": [ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", "facts", + "temporal_refs", "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring" + ] + }, "description": "Which extraction capabilities the producer attempted to fill." }, "embeddings": { @@ -80,6 +93,9 @@ }, "extensions": { "type": "object", + "propertyNames": { + "pattern": "^[a-zA-Z0-9_\\-]+/[a-zA-Z0-9_\\-]+$" + }, "additionalProperties": true, "description": "Domain-specific structured extras. Keys are scoped: \"vendor/kind\"." } @@ -99,10 +115,12 @@ "properties": { "target": { "type": "string", + "minLength": 1, "description": "Extraction-local ID of the target entity." }, "type": { "type": "string", + "minLength": 1, "description": "Relationship type." }, "origin": { @@ -124,10 +142,12 @@ }, "name": { "type": "string", + "minLength": 1, "description": "Display name as it appeared in the source text." }, "type": { "type": "string", + "minLength": 1, "description": "Entity type: \"person\", \"place\", \"event\", \"concept\", \"organization\", \"object\", or custom string." }, "state": { @@ -159,6 +179,7 @@ "properties": { "text": { "type": "string", + "minLength": 1, "description": "Goal description." }, "status": { @@ -191,6 +212,7 @@ "properties": { "text": { "type": "string", + "minLength": 1, "description": "The factual statement." }, "category": { diff --git a/schemas/source-ref/v1.json b/schemas/source-ref/v1.json index 4035731..351cb99 100644 --- a/schemas/source-ref/v1.json +++ b/schemas/source-ref/v1.json @@ -30,5 +30,6 @@ } }, "required": ["version"], + "minProperties": 2, "additionalProperties": false } diff --git a/schemas/temporal-ref/v1.json b/schemas/temporal-ref/v1.json index 2976188..e722a9b 100644 --- a/schemas/temporal-ref/v1.json +++ b/schemas/temporal-ref/v1.json @@ -11,6 +11,7 @@ }, "raw": { "type": "string", + "minLength": 1, "description": "The temporal expression as it appeared in the source text." }, "type": { From 50e0e1397bd9e091023189405844e458ec2822e3 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:36:42 -0500 Subject: [PATCH 09/12] fix: tighten validators to match JSON Schema constraints Both Python and TypeScript validators now enforce: 1. additionalProperties: false on all object types (root, entity, goal, fact, relation, source-ref, signals, temporal-ref, embedding) 2. Type checks on optional string fields (sentiment, source_id, source_type, user_id, entity state/context/date_hint, fact category, relation origin, temporal context, embedding space) 3. Source-ref offset constraints (offset_start, offset_end, sentence_index must be non-negative integers) 4. Goal entity_refs items must be strings 5. Embedding computed_at must be strict date-time 6. Boolean guard on isinstance checks (Python bool is int subclass) Adds 24 new tests: 9 additional-properties, 11 type-check, 4 offset. 198 total Python tests passing. Closes the "validators too loose" class from Atlas's drift audit. Co-Authored-By: Claude Opus 4.6 --- .../python/src/synapt_extract/validate.py | 88 +++++++- packages/ts/src/validate.ts | 97 ++++++++- tests/python/test_validate.py | 197 ++++++++++++++++++ 3 files changed, 365 insertions(+), 17 deletions(-) diff --git a/packages/python/src/synapt_extract/validate.py b/packages/python/src/synapt_extract/validate.py index 673adb2..a85ae06 100644 --- a/packages/python/src/synapt_extract/validate.py +++ b/packages/python/src/synapt_extract/validate.py @@ -24,6 +24,26 @@ r"(?:Z|[+\-]\d{2}:?\d{2})?$" ) +_ROOT_KEYS = frozenset([ + "version", "extracted_at", "source_id", "source_type", "user_id", + "produced_by", "kind", "entities", "goals", "themes", "sentiment", + "summary", "facts", "temporal_refs", "capabilities", "embeddings", "extensions", +]) +_ENTITY_KEYS = frozenset([ + "id", "name", "type", "state", "context", "date_hint", + "source", "signals", "relations", +]) +_GOAL_KEYS = frozenset([ + "text", "status", "entity_refs", "stated_at", "resolved_at", + "source", "signals", +]) +_FACT_KEYS = frozenset(["text", "category", "source", "signals"]) +_RELATION_KEYS = frozenset(["target", "type", "origin", "signals"]) +_SOURCE_REF_KEYS = frozenset(["version", "snippet", "offset_start", "offset_end", "sentence_index"]) +_SIGNALS_KEYS = frozenset(["version", "confidence", "negated", "hedged", "condition"]) +_TEMPORAL_REF_KEYS = frozenset(["version", "raw", "type", "resolved", "resolved_end", "context"]) +_EMBEDDING_KEYS = frozenset(["version", "vector", "model", "input", "dimensions", "space", "computed_at"]) + @dataclass class ValidationError: @@ -53,6 +73,13 @@ def _is_namespaced(s: str) -> bool: return bool(_NAMESPACED_RE.match(s)) +def _check_extra_keys(obj: dict, allowed: frozenset[str], path: str, errors: list[ValidationError]) -> None: + for key in obj: + if key not in allowed: + full_path = f"{path}.{key}" if path else key + errors.append(ValidationError(full_path, "additional property not allowed")) + + def _require_non_empty_str(obj: dict, key: str, path: str, errors: list[ValidationError], label: str = "required non-empty string") -> bool: val = obj.get(key) if not isinstance(val, str) or len(val) == 0: @@ -61,6 +88,18 @@ def _require_non_empty_str(obj: dict, key: str, path: str, errors: list[Validati return True +def _check_optional_str(obj: dict, key: str, path: str, errors: list[ValidationError]) -> None: + if key in obj and not isinstance(obj[key], str): + errors.append(ValidationError(f"{path}.{key}", "must be a string")) + + +def _check_optional_non_neg_int(obj: dict, key: str, path: str, errors: list[ValidationError]) -> None: + if key in obj: + val = obj[key] + if not isinstance(val, int) or isinstance(val, bool) or val < 0: + errors.append(ValidationError(f"{path}.{key}", "must be a non-negative integer")) + + def _has_payload_beyond_version(obj: dict[str, Any]) -> bool: return any(k != "version" for k in obj) @@ -69,19 +108,23 @@ def _check_source_ref(obj: Any, path: str, errors: list[ValidationError]) -> Non if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _SOURCE_REF_KEYS, path, errors) if obj.get("version") != "1": errors.append(ValidationError(f"{path}.version", 'must be "1"')) if not _has_payload_beyond_version(obj): errors.append(ValidationError(path, "empty sub-schema (only version); must contain at least one payload field")) return - if "snippet" in obj and not isinstance(obj["snippet"], str): - errors.append(ValidationError(f"{path}.snippet", "must be a string")) + _check_optional_str(obj, "snippet", path, errors) + _check_optional_non_neg_int(obj, "offset_start", path, errors) + _check_optional_non_neg_int(obj, "offset_end", path, errors) + _check_optional_non_neg_int(obj, "sentence_index", path, errors) def _check_signals(obj: Any, path: str, errors: list[ValidationError]) -> None: if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _SIGNALS_KEYS, path, errors) if obj.get("version") != "1": errors.append(ValidationError(f"{path}.version", 'must be "1"')) if not _has_payload_beyond_version(obj): @@ -89,7 +132,7 @@ def _check_signals(obj: Any, path: str, errors: list[ValidationError]) -> None: return if "confidence" in obj: c = obj["confidence"] - if not isinstance(c, (int, float)) or c < 0 or c > 1: + if not isinstance(c, (int, float)) or isinstance(c, bool) or c < 0 or c > 1: errors.append(ValidationError(f"{path}.confidence", "must be a number between 0.0 and 1.0")) if "negated" in obj and not isinstance(obj["negated"], bool): errors.append(ValidationError(f"{path}.negated", "must be a boolean")) @@ -103,6 +146,7 @@ def _check_embedding(obj: Any, path: str, errors: list[ValidationError]) -> None if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _EMBEDDING_KEYS, path, errors) if obj.get("version") != "1": errors.append(ValidationError(f"{path}.version", 'must be "1"')) vector = obj.get("vector") @@ -116,22 +160,28 @@ def _check_embedding(obj: Any, path: str, errors: list[ValidationError]) -> None if not isinstance(obj.get("input"), str): errors.append(ValidationError(f"{path}.input", "required string")) dims = obj.get("dimensions") - if not isinstance(dims, int) or dims < 1: + if not isinstance(dims, int) or isinstance(dims, bool) or dims < 1: errors.append(ValidationError(f"{path}.dimensions", "required positive integer")) elif isinstance(vector, list) and dims != len(vector): errors.append(ValidationError(f"{path}.dimensions", f"dimensions ({dims}) must equal vector length ({len(vector)})")) + _check_optional_str(obj, "space", path, errors) + if "computed_at" in obj: + if not isinstance(obj["computed_at"], str) or not _is_iso_datetime_strict(obj["computed_at"]): + errors.append(ValidationError(f"{path}.computed_at", "must be a valid ISO 8601 date-time")) def _check_relation(obj: Any, path: str, errors: list[ValidationError]) -> None: if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _RELATION_KEYS, path, errors) target = obj.get("target") if not isinstance(target, str) or len(target) == 0: errors.append(ValidationError(f"{path}.target", "required non-empty string")) rtype = obj.get("type") if not isinstance(rtype, str) or len(rtype) == 0: errors.append(ValidationError(f"{path}.type", "required non-empty string")) + _check_optional_str(obj, "origin", path, errors) if "signals" in obj: _check_signals(obj["signals"], f"{path}.signals", errors) @@ -140,8 +190,13 @@ def _check_entity(obj: Any, path: str, errors: list[ValidationError]) -> None: if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _ENTITY_KEYS, path, errors) _require_non_empty_str(obj, "name", path, errors) _require_non_empty_str(obj, "type", path, errors) + _check_optional_str(obj, "id", path, errors) + _check_optional_str(obj, "state", path, errors) + _check_optional_str(obj, "context", path, errors) + _check_optional_str(obj, "date_hint", path, errors) if "source" in obj: _check_source_ref(obj["source"], f"{path}.source", errors) if "signals" in obj: @@ -158,12 +213,17 @@ def _check_goal(obj: Any, path: str, errors: list[ValidationError]) -> None: if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _GOAL_KEYS, path, errors) _require_non_empty_str(obj, "text", path, errors) status = obj.get("status") if not isinstance(status, str) or status not in VALID_GOAL_STATUSES: errors.append(ValidationError(f"{path}.status", "must be one of: open, resolved, abandoned, in_progress")) if not isinstance(obj.get("entity_refs"), list): errors.append(ValidationError(f"{path}.entity_refs", "required array of strings")) + else: + for i, ref in enumerate(obj["entity_refs"]): + if not isinstance(ref, str): + errors.append(ValidationError(f"{path}.entity_refs[{i}]", "must be a string")) if "stated_at" in obj: if not isinstance(obj["stated_at"], str) or not _is_iso_datetime(obj["stated_at"]): errors.append(ValidationError(f"{path}.stated_at", "must be a valid ISO 8601 date/datetime")) @@ -180,7 +240,9 @@ def _check_fact(obj: Any, path: str, errors: list[ValidationError]) -> None: if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _FACT_KEYS, path, errors) _require_non_empty_str(obj, "text", path, errors) + _check_optional_str(obj, "category", path, errors) if "source" in obj: _check_source_ref(obj["source"], f"{path}.source", errors) if "signals" in obj: @@ -191,6 +253,7 @@ def _check_temporal_ref(obj: Any, path: str, errors: list[ValidationError]) -> N if not isinstance(obj, dict): errors.append(ValidationError(path, "must be an object")) return + _check_extra_keys(obj, _TEMPORAL_REF_KEYS, path, errors) if obj.get("version") != "1": errors.append(ValidationError(f"{path}.version", 'must be "1"')) raw = obj.get("raw") @@ -213,19 +276,17 @@ def _check_temporal_ref(obj: Any, path: str, errors: list[ValidationError]) -> N if "resolved_end" in obj: if not isinstance(obj["resolved_end"], str) or not _is_iso_datetime(obj["resolved_end"]): errors.append(ValidationError(f"{path}.resolved_end", "must be a valid ISO 8601 date/datetime")) + _check_optional_str(obj, "context", path, errors) def validate_extraction(obj: Any) -> ValidationResult: - """Validate a SynaptExtraction document for structural conformance. - - Returns a ValidationResult with valid=True if the document conforms - to the IL v1 schema, or valid=False with a list of errors. - """ errors: list[ValidationError] = [] if not isinstance(obj, dict): return ValidationResult(valid=False, errors=[ValidationError("", "must be an object")]) + _check_extra_keys(obj, _ROOT_KEYS, "", errors) + if obj.get("version") != "1": errors.append(ValidationError("version", 'must be "1"')) @@ -246,9 +307,16 @@ def validate_extraction(obj: Any) -> ValidationResult: if not isinstance(kind, str) or not _is_namespaced(kind): errors.append(ValidationError("kind", "must be namespaced (e.g. 'conversa/prayer')")) + _check_optional_str(obj, "sentiment", "", errors) + _check_optional_str(obj, "source_id", "", errors) + _check_optional_str(obj, "source_type", "", errors) + _check_optional_str(obj, "user_id", "", errors) + if "extensions" in obj: ext = obj["extensions"] - if isinstance(ext, dict): + if not isinstance(ext, dict): + errors.append(ValidationError("extensions", "must be an object")) + else: for key in ext: if not _is_namespaced(key): errors.append(ValidationError(f"extensions.{key}", "extension key must be namespaced (e.g. 'conversa/prayer')")) diff --git a/packages/ts/src/validate.ts b/packages/ts/src/validate.ts index 5909bf3..37fc625 100644 --- a/packages/ts/src/validate.ts +++ b/packages/ts/src/validate.ts @@ -32,6 +32,26 @@ const NAMESPACED_RE = /^[a-zA-Z0-9_\-]+\/[a-zA-Z0-9_\-]+$/; const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+\-]\d{2}:?\d{2})?)?$/; const ISO_DATETIME_STRICT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+\-]\d{2}:?\d{2})?$/; +const ROOT_KEYS = new Set([ + "version", "extracted_at", "source_id", "source_type", "user_id", + "produced_by", "kind", "entities", "goals", "themes", "sentiment", + "summary", "facts", "temporal_refs", "capabilities", "embeddings", "extensions", +]); +const ENTITY_KEYS = new Set([ + "id", "name", "type", "state", "context", "date_hint", + "source", "signals", "relations", +]); +const GOAL_KEYS = new Set([ + "text", "status", "entity_refs", "stated_at", "resolved_at", + "source", "signals", +]); +const FACT_KEYS = new Set(["text", "category", "source", "signals"]); +const RELATION_KEYS = new Set(["target", "type", "origin", "signals"]); +const SOURCE_REF_KEYS = new Set(["version", "snippet", "offset_start", "offset_end", "sentence_index"]); +const SIGNALS_KEYS = new Set(["version", "confidence", "negated", "hedged", "condition"]); +const TEMPORAL_REF_KEYS = new Set(["version", "raw", "type", "resolved", "resolved_end", "context"]); +const EMBEDDING_KEYS = new Set(["version", "vector", "model", "input", "dimensions", "space", "computed_at"]); + function isUri(s: string): boolean { return URI_RE.test(s); } @@ -48,6 +68,30 @@ function isNamespaced(s: string): boolean { return NAMESPACED_RE.test(s); } +function checkExtraKeys(obj: Record, allowed: Set, path: string, errors: ValidationError[]): void { + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) { + const fullPath = path ? `${path}.${key}` : key; + errors.push({ path: fullPath, message: "additional property not allowed" }); + } + } +} + +function checkOptionalStr(obj: Record, key: string, path: string, errors: ValidationError[]): void { + if (obj[key] !== undefined && typeof obj[key] !== "string") { + errors.push({ path: `${path}.${key}`, message: "must be a string" }); + } +} + +function checkOptionalNonNegInt(obj: Record, key: string, path: string, errors: ValidationError[]): void { + if (obj[key] !== undefined) { + const val = obj[key]; + if (typeof val !== "number" || !Number.isInteger(val) || val < 0) { + errors.push({ path: `${path}.${key}`, message: "must be a non-negative integer" }); + } + } +} + function hasPayloadBeyondVersion(obj: Record): boolean { return Object.keys(obj).some((k) => k !== "version"); } @@ -58,6 +102,7 @@ function validateSourceRef(obj: unknown, path: string, errors: ValidationError[] return; } const ref = obj as Record; + checkExtraKeys(ref, SOURCE_REF_KEYS, path, errors); if (ref.version !== "1") { errors.push({ path: `${path}.version`, message: "must be \"1\"" }); } @@ -65,9 +110,10 @@ function validateSourceRef(obj: unknown, path: string, errors: ValidationError[] errors.push({ path, message: "empty sub-schema (only version); must contain at least one payload field" }); return; } - if (ref.snippet !== undefined && typeof ref.snippet !== "string") { - errors.push({ path: `${path}.snippet`, message: "must be a string" }); - } + checkOptionalStr(ref, "snippet", path, errors); + checkOptionalNonNegInt(ref, "offset_start", path, errors); + checkOptionalNonNegInt(ref, "offset_end", path, errors); + checkOptionalNonNegInt(ref, "sentence_index", path, errors); } function validateSignals(obj: unknown, path: string, errors: ValidationError[]): void { @@ -76,6 +122,7 @@ function validateSignals(obj: unknown, path: string, errors: ValidationError[]): return; } const sig = obj as Record; + checkExtraKeys(sig, SIGNALS_KEYS, path, errors); if (sig.version !== "1") { errors.push({ path: `${path}.version`, message: "must be \"1\"" }); } @@ -105,6 +152,7 @@ function validateEmbedding(obj: unknown, path: string, errors: ValidationError[] return; } const emb = obj as Record; + checkExtraKeys(emb, EMBEDDING_KEYS, path, errors); if (emb.version !== "1") { errors.push({ path: `${path}.version`, message: "must be \"1\"" }); } @@ -125,6 +173,12 @@ function validateEmbedding(obj: unknown, path: string, errors: ValidationError[] } else if (Array.isArray(vector) && emb.dimensions !== vector.length) { errors.push({ path: `${path}.dimensions`, message: `dimensions (${emb.dimensions}) must equal vector length (${vector.length})` }); } + checkOptionalStr(emb, "space", path, errors); + if (emb.computed_at !== undefined) { + if (typeof emb.computed_at !== "string" || !isIsoDatetimeStrict(emb.computed_at)) { + errors.push({ path: `${path}.computed_at`, message: "must be a valid ISO 8601 date-time" }); + } + } } function validateRelation(obj: unknown, path: string, errors: ValidationError[]): void { @@ -133,12 +187,14 @@ function validateRelation(obj: unknown, path: string, errors: ValidationError[]) return; } const rel = obj as Record; + checkExtraKeys(rel, RELATION_KEYS, path, errors); if (typeof rel.target !== "string" || rel.target.length === 0) { errors.push({ path: `${path}.target`, message: "required non-empty string" }); } if (typeof rel.type !== "string" || rel.type.length === 0) { errors.push({ path: `${path}.type`, message: "required non-empty string" }); } + checkOptionalStr(rel, "origin", path, errors); if (rel.signals !== undefined) { validateSignals(rel.signals, `${path}.signals`, errors); } @@ -150,12 +206,17 @@ function validateEntity(obj: unknown, path: string, errors: ValidationError[]): return; } const ent = obj as Record; + checkExtraKeys(ent, ENTITY_KEYS, path, errors); if (typeof ent.name !== "string" || ent.name.length === 0) { errors.push({ path: `${path}.name`, message: "required non-empty string" }); } if (typeof ent.type !== "string" || ent.type.length === 0) { errors.push({ path: `${path}.type`, message: "required non-empty string" }); } + checkOptionalStr(ent, "id", path, errors); + checkOptionalStr(ent, "state", path, errors); + checkOptionalStr(ent, "context", path, errors); + checkOptionalStr(ent, "date_hint", path, errors); if (ent.source !== undefined) { validateSourceRef(ent.source, `${path}.source`, errors); } @@ -179,6 +240,7 @@ function validateGoal(obj: unknown, path: string, errors: ValidationError[]): vo return; } const goal = obj as Record; + checkExtraKeys(goal, GOAL_KEYS, path, errors); if (typeof goal.text !== "string" || goal.text.length === 0) { errors.push({ path: `${path}.text`, message: "required non-empty string" }); } @@ -187,6 +249,12 @@ function validateGoal(obj: unknown, path: string, errors: ValidationError[]): vo } if (!Array.isArray(goal.entity_refs)) { errors.push({ path: `${path}.entity_refs`, message: "required array of strings" }); + } else { + for (let i = 0; i < goal.entity_refs.length; i++) { + if (typeof goal.entity_refs[i] !== "string") { + errors.push({ path: `${path}.entity_refs[${i}]`, message: "must be a string" }); + } + } } if (goal.stated_at !== undefined) { if (typeof goal.stated_at !== "string" || !isIsoDatetime(goal.stated_at)) { @@ -212,9 +280,11 @@ function validateFact(obj: unknown, path: string, errors: ValidationError[]): vo return; } const fact = obj as Record; + checkExtraKeys(fact, FACT_KEYS, path, errors); if (typeof fact.text !== "string" || fact.text.length === 0) { errors.push({ path: `${path}.text`, message: "required non-empty string" }); } + checkOptionalStr(fact, "category", path, errors); if (fact.source !== undefined) { validateSourceRef(fact.source, `${path}.source`, errors); } @@ -229,6 +299,7 @@ function validateTemporalRef(obj: unknown, path: string, errors: ValidationError return; } const ref = obj as Record; + checkExtraKeys(ref, TEMPORAL_REF_KEYS, path, errors); if (ref.version !== "1") { errors.push({ path: `${path}.version`, message: "must be \"1\"" }); } @@ -259,6 +330,7 @@ function validateTemporalRef(obj: unknown, path: string, errors: ValidationError errors.push({ path: `${path}.resolved_end`, message: "must be a valid ISO 8601 date/datetime" }); } } + checkOptionalStr(ref, "context", path, errors); } export function validateExtraction(obj: unknown): ValidationResult { @@ -270,6 +342,8 @@ export function validateExtraction(obj: unknown): ValidationResult { const doc = obj as Record; + checkExtraKeys(doc, ROOT_KEYS, "", errors); + if (doc.version !== "1") { errors.push({ path: "version", message: "must be \"1\"" }); } @@ -292,10 +366,19 @@ export function validateExtraction(obj: unknown): ValidationResult { } } - if (doc.extensions !== undefined && typeof doc.extensions === "object" && doc.extensions !== null) { - for (const key of Object.keys(doc.extensions as Record)) { - if (!isNamespaced(key)) { - errors.push({ path: `extensions.${key}`, message: "extension key must be namespaced (e.g. 'conversa/prayer')" }); + checkOptionalStr(doc, "sentiment", "", errors); + checkOptionalStr(doc, "source_id", "", errors); + checkOptionalStr(doc, "source_type", "", errors); + checkOptionalStr(doc, "user_id", "", errors); + + if (doc.extensions !== undefined) { + if (typeof doc.extensions !== "object" || doc.extensions === null || Array.isArray(doc.extensions)) { + errors.push({ path: "extensions", message: "must be an object" }); + } else { + for (const key of Object.keys(doc.extensions as Record)) { + if (!isNamespaced(key)) { + errors.push({ path: `extensions.${key}`, message: "extension key must be namespaced (e.g. 'conversa/prayer')" }); + } } } } diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index 8e8a901..decbc52 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -717,6 +717,203 @@ def test_kind_valid_namespace(self): assert result.valid +class TestAdditionalProperties: + + def test_root_extra_property_rejected(self): + doc = _minimal_extraction() + doc["extra"] = True + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path and "additional" in e.message for e in result.errors) + + def test_entity_extra_property_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", "extra_field": True, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra_field" in e.path for e in result.errors) + + def test_goal_extra_property_rejected(self): + doc = _minimal_extraction(goals=[{ + "text": "Recovery", "status": "open", "entity_refs": [], + "custom": "value", + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("custom" in e.path for e in result.errors) + + def test_fact_extra_property_rejected(self): + doc = _minimal_extraction(facts=[{"text": "A fact", "extra": 1}]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path for e in result.errors) + + def test_relation_extra_property_rejected(self): + doc = _minimal_extraction(entities=[{ + "id": "e1", "name": "Mom", "type": "person", + "relations": [{"target": "e2", "type": "knows", "weight": 0.5}], + }, {"id": "e2", "name": "Dad", "type": "person"}]) + result = validate_extraction(doc) + assert not result.valid + assert any("weight" in e.path for e in result.errors) + + def test_source_ref_extra_property_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "source": {"version": "1", "snippet": "text", "extra": True}, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path for e in result.errors) + + def test_signals_extra_property_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "signals": {"version": "1", "confidence": 0.9, "extra": True}, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path for e in result.errors) + + def test_temporal_ref_extra_property_rejected(self): + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "April 20", "extra": True, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path for e in result.errors) + + def test_embedding_extra_property_rejected(self): + doc = _minimal_extraction(embeddings=[{ + "version": "1", "vector": [0.1, 0.2], "model": "openai://emb", + "input": "source", "dimensions": 2, "extra": True, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("extra" in e.path for e in result.errors) + + +class TestOptionalFieldTypes: + + def test_sentiment_must_be_string(self): + doc = _minimal_extraction(sentiment=3) + result = validate_extraction(doc) + assert not result.valid + assert any("sentiment" in e.path for e in result.errors) + + def test_source_id_must_be_string(self): + doc = _minimal_extraction(source_id=123) + result = validate_extraction(doc) + assert not result.valid + assert any("source_id" in e.path for e in result.errors) + + def test_source_type_must_be_string(self): + doc = _minimal_extraction(source_type=True) + result = validate_extraction(doc) + assert not result.valid + assert any("source_type" in e.path for e in result.errors) + + def test_user_id_must_be_string(self): + doc = _minimal_extraction(user_id=42) + result = validate_extraction(doc) + assert not result.valid + assert any("user_id" in e.path for e in result.errors) + + def test_entity_state_must_be_string(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", "state": 7, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("state" in e.path for e in result.errors) + + def test_entity_context_must_be_string(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", "context": 7, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("context" in e.path for e in result.errors) + + def test_entity_date_hint_must_be_string(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", "date_hint": 7, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("date_hint" in e.path for e in result.errors) + + def test_fact_category_must_be_string(self): + doc = _minimal_extraction(facts=[{"text": "A fact", "category": 1}]) + result = validate_extraction(doc) + assert not result.valid + assert any("category" in e.path for e in result.errors) + + def test_relation_origin_must_be_string(self): + doc = _minimal_extraction(entities=[{ + "id": "e1", "name": "Mom", "type": "person", + "relations": [{"target": "e2", "type": "knows", "origin": 1}], + }, {"id": "e2", "name": "Dad", "type": "person"}]) + result = validate_extraction(doc) + assert not result.valid + assert any("origin" in e.path for e in result.errors) + + def test_goal_entity_refs_items_must_be_strings(self): + doc = _minimal_extraction(goals=[{ + "text": "Recovery", "status": "open", "entity_refs": [1], + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("entity_refs" in e.path for e in result.errors) + + def test_temporal_context_must_be_string(self): + doc = _minimal_extraction(temporal_refs=[{ + "version": "1", "raw": "April 20", "context": 1, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("context" in e.path for e in result.errors) + + +class TestSourceRefOffsets: + + def test_offset_start_negative_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "source": {"version": "1", "snippet": "text", "offset_start": -1}, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("offset_start" in e.path for e in result.errors) + + def test_offset_start_string_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "source": {"version": "1", "snippet": "text", "offset_start": "1"}, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("offset_start" in e.path for e in result.errors) + + def test_offset_start_valid(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "source": {"version": "1", "snippet": "text", "offset_start": 0, "offset_end": 4}, + }]) + result = validate_extraction(doc) + assert result.valid + + def test_sentence_index_negative_rejected(self): + doc = _minimal_extraction(entities=[{ + "name": "Mom", "type": "person", + "source": {"version": "1", "snippet": "text", "sentence_index": -1}, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("sentence_index" in e.path for e in result.errors) + + class TestJsonSchemaFiles: def test_all_schema_files_are_valid_json(self): From 8bac94a6faeba6a3b67c2fcadfe385d29485cf0d Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:46:45 -0500 Subject: [PATCH 10/12] fix: validate embedding vector item types Both validators now reject non-number items in embedding.vector arrays. Uses early-break to report the first bad element without flooding errors on large vectors. Co-Authored-By: Claude Opus 4.6 --- .../python/src/synapt_extract/validate.py | 5 +++ packages/ts/src/validate.ts | 7 ++++ tests/python/test_validate.py | 35 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/packages/python/src/synapt_extract/validate.py b/packages/python/src/synapt_extract/validate.py index a85ae06..1067244 100644 --- a/packages/python/src/synapt_extract/validate.py +++ b/packages/python/src/synapt_extract/validate.py @@ -152,6 +152,11 @@ def _check_embedding(obj: Any, path: str, errors: list[ValidationError]) -> None vector = obj.get("vector") if not isinstance(vector, list): errors.append(ValidationError(f"{path}.vector", "required array")) + else: + for i, v in enumerate(vector): + if not isinstance(v, (int, float)) or isinstance(v, bool): + errors.append(ValidationError(f"{path}.vector[{i}]", "must be a number")) + break model = obj.get("model") if not isinstance(model, str): errors.append(ValidationError(f"{path}.model", "required string")) diff --git a/packages/ts/src/validate.ts b/packages/ts/src/validate.ts index 37fc625..cff1f4d 100644 --- a/packages/ts/src/validate.ts +++ b/packages/ts/src/validate.ts @@ -159,6 +159,13 @@ function validateEmbedding(obj: unknown, path: string, errors: ValidationError[] const vector = emb.vector; if (!Array.isArray(vector)) { errors.push({ path: `${path}.vector`, message: "required array" }); + } else { + for (let i = 0; i < vector.length; i++) { + if (typeof vector[i] !== "number") { + errors.push({ path: `${path}.vector[${i}]`, message: "must be a number" }); + break; + } + } } if (typeof emb.model !== "string") { errors.push({ path: `${path}.model`, message: "required string" }); diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py index decbc52..ae3ee18 100644 --- a/tests/python/test_validate.py +++ b/tests/python/test_validate.py @@ -555,6 +555,41 @@ def test_dimensions_match_accepted(self): result = validate_extraction(doc) assert result.valid + def test_vector_non_number_rejected(self): + doc = _minimal_extraction(embeddings=[{ + "version": "1", + "vector": [0.1, "bad", 0.3], + "model": "openai://text-embedding-3-small", + "input": "source", + "dimensions": 3, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("vector" in e.path for e in result.errors) + + def test_vector_bool_rejected(self): + doc = _minimal_extraction(embeddings=[{ + "version": "1", + "vector": [0.1, True, 0.3], + "model": "openai://text-embedding-3-small", + "input": "source", + "dimensions": 3, + }]) + result = validate_extraction(doc) + assert not result.valid + assert any("vector" in e.path for e in result.errors) + + def test_vector_all_numbers_accepted(self): + doc = _minimal_extraction(embeddings=[{ + "version": "1", + "vector": [0.1, 0.2, 0.3], + "model": "openai://text-embedding-3-small", + "input": "source", + "dimensions": 3, + }]) + result = validate_extraction(doc) + assert result.valid + def test_embedding_model_requires_scheme(self): doc = _minimal_extraction(embeddings=[{ "version": "1", From 009647393ddf0be7247ee2cc0fac8684da2aa9a5 Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:06:34 -0500 Subject: [PATCH 11/12] test: add ts parity suite for extract --- packages/ts/package-lock.json | 1436 ++++++++++++++++++++++++- packages/ts/package.json | 8 +- packages/ts/tests/test_finalize.ts | 283 +++++ packages/ts/tests/test_prompt.ts | 280 +++++ packages/ts/tests/test_validate.ts | 622 +++++++++++ packages/ts/vitest.config.ts | 7 + tests/conformance/finalize_cases.json | 100 ++ tests/conformance/prompt_cases.json | 37 + tests/conformance/validate_cases.json | 63 ++ tests/python/test_conformance.py | 65 ++ 10 files changed, 2899 insertions(+), 2 deletions(-) create mode 100644 packages/ts/tests/test_finalize.ts create mode 100644 packages/ts/tests/test_prompt.ts create mode 100644 packages/ts/tests/test_validate.ts create mode 100644 packages/ts/vitest.config.ts create mode 100644 tests/conformance/finalize_cases.json create mode 100644 tests/conformance/prompt_cases.json create mode 100644 tests/conformance/validate_cases.json create mode 100644 tests/python/test_conformance.py diff --git a/packages/ts/package-lock.json b/packages/ts/package-lock.json index 868e4e2..9ea1c26 100644 --- a/packages/ts/package-lock.json +++ b/packages/ts/package-lock.json @@ -10,12 +10,418 @@ "license": "MIT", "devDependencies": { "@types/node": "^25.6.0", - "typescript": "^5.5.0" + "@vitest/ui": "^4.1.5", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "typescript": "^5.5.0", + "vitest": "^4.1.5" }, "engines": { "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -26,6 +432,849 @@ "undici-types": "~7.19.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", + "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.5" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -46,6 +1295,191 @@ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/packages/ts/package.json b/packages/ts/package.json index 62eaaf6..990ef07 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -23,6 +23,8 @@ "scripts": { "build": "tsc", "check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "prepack": "test -d ../../prompts && cp -r ../../prompts ./prompts || true" }, "keywords": [ @@ -44,6 +46,10 @@ }, "devDependencies": { "@types/node": "^25.6.0", - "typescript": "^5.5.0" + "@vitest/ui": "^4.1.5", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "typescript": "^5.5.0", + "vitest": "^4.1.5" } } diff --git a/packages/ts/tests/test_finalize.ts b/packages/ts/tests/test_finalize.ts new file mode 100644 index 0000000..96ad751 --- /dev/null +++ b/packages/ts/tests/test_finalize.ts @@ -0,0 +1,283 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { finalizeExtraction } from "../src/finalize.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); +const CONFORMANCE_DIR = resolve(REPO_ROOT, "tests", "conformance"); + +function loadJson(...parts: string[]): T { + return JSON.parse(readFileSync(resolve(...parts), "utf-8")) as T; +} + +function llmOutput(overrides: Record = {}): Record { + return { + extracted_at: "2026-04-26T00:00:00Z", + entities: [{ name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: [] }], + themes: ["Health"], + ...overrides, + }; +} + +describe("finalizeExtraction", () => { + test.each([ + ["injects version", {}, "version", "1"], + ["injects produced_by", {}, "produced_by", "openai://gpt-4o-mini"], + ["injects user_id", { user_id: "u123" }, "user_id", "u123"], + ["injects source_id", { source_id: "prayer-001" }, "source_id", "prayer-001"], + ["injects kind", { kind: "conversa/prayer" }, "kind", "conversa/prayer"], + ])("%s", (_name, extraContext, field, expected) => { + const result = finalizeExtraction( + llmOutput(), + { produced_by: "openai://gpt-4o-mini", ...extraContext }, + ); + expect(result.extraction[field as keyof typeof result.extraction]).toBe(expected); + }); + + test("injects extensions and extension versions", () => { + const result = finalizeExtraction(llmOutput(), { + produced_by: "test://model", + extensions: { "conversa/prayer": { category: "Health" } }, + }); + expect(result.extraction.extensions?.["conversa/prayer"]).toEqual({ + version: "1", + category: "Health", + }); + }); + + test("injects multiple extension versions and preserves scalar extensions", () => { + const result = finalizeExtraction(llmOutput(), { + produced_by: "test://model", + extensions: { + "conversa/prayer": { category: "Health" }, + "conversa/sermon": { topic: "Grace" }, + "conversa/scalar": "simple_string", + }, + }); + expect(result.extraction.extensions?.["conversa/prayer"]).toMatchObject({ version: "1" }); + expect(result.extraction.extensions?.["conversa/sermon"]).toMatchObject({ version: "1" }); + expect(result.extraction.extensions?.["conversa/scalar"]).toBe("simple_string"); + }); + + test("injects embedding version and auto-populates dimensions", () => { + const result = finalizeExtraction(llmOutput(), { + produced_by: "test://model", + embeddings: [{ + vector: [0.1, 0.2, 0.3, 0.4], + model: "openai://text-embedding-3-small", + input: "source", + }], + }); + expect(result.extraction.embeddings?.[0]).toMatchObject({ + version: "1", + dimensions: 4, + }); + }); + + test.each([ + [ + "injects source ref version", + llmOutput({ entities: [{ name: "Mom", type: "person", source: { snippet: "My mom" } }] }), + ["entities", 0, "source", "version"], + "1", + ], + [ + "injects signals version", + llmOutput({ entities: [{ name: "Mom", type: "person", signals: { confidence: 0.9 } }] }), + ["entities", 0, "signals", "version"], + "1", + ], + [ + "injects temporal ref version", + llmOutput({ temporal_refs: [{ raw: "next week", type: "range" }] }), + ["temporal_refs", 0, "version"], + "1", + ], + [ + "injects goal source version", + llmOutput({ goals: [{ text: "Recovery", status: "open", entity_refs: [], source: { snippet: "I hope she recovers" } }] }), + ["goals", 0, "source", "version"], + "1", + ], + [ + "injects fact source version", + llmOutput({ facts: [{ text: "Surgery happened", source: { snippet: "Mom had surgery" } }] }), + ["facts", 0, "source", "version"], + "1", + ], + [ + "injects relation signals version", + llmOutput({ + entities: [ + { + id: "e1", + name: "Mom", + type: "person", + relations: [{ target: "e2", type: "parent_of", signals: { confidence: 0.8 } }], + }, + { id: "e2", name: "Me", type: "person" }, + ], + }), + ["entities", 0, "relations", 0, "signals", "version"], + "1", + ], + ])("%s", (_name, doc, path, expected) => { + const result = finalizeExtraction(doc, { produced_by: "test://model" }); + let current: unknown = result.extraction; + for (const key of path) { + current = (current as Record | unknown[])[key as never]; + } + expect(current).toBe(expected); + }); + + test("strips empty source and version-only signals", () => { + const result = finalizeExtraction(llmOutput({ + entities: [{ name: "Mom", type: "person", source: {}, signals: { version: "1" } }], + }), { produced_by: "test://model" }); + expect(result.extraction.entities[0]).not.toHaveProperty("source"); + expect(result.extraction.entities[0]).not.toHaveProperty("signals"); + }); + + test.each([ + ["entities", llmOutput(), "entities"], + ["entity_state", llmOutput({ entities: [{ name: "Mom", type: "person", state: "recovering" }] }), "entity_state"], + ["entity_ids", llmOutput({ entities: [{ id: "e1", name: "Mom", type: "person" }] }), "entity_ids"], + [ + "relations", + llmOutput({ + entities: [ + { id: "e1", name: "Mom", type: "person", relations: [{ target: "e2", type: "knows" }] }, + { id: "e2", name: "Dad", type: "person" }, + ], + }), + "relations", + ], + ["evidence_anchoring", llmOutput({ entities: [{ name: "Mom", type: "person", source: { snippet: "My mom" } }] }), "evidence_anchoring"], + ["assertion_signals", llmOutput({ entities: [{ name: "Mom", type: "person", signals: { confidence: 0.9 } }] }), "assertion_signals"], + ["goal_timing", llmOutput({ goals: [{ text: "Recovery", status: "open", entity_refs: [], stated_at: "2026-04-20" }] }), "goal_timing"], + ["summary", llmOutput({ summary: "A prayer for healing.", sentiment: "hopeful" }), "summary"], + ["sentiment", llmOutput({ summary: "A prayer for healing.", sentiment: "hopeful" }), "sentiment"], + [ + "temporal_classes", + llmOutput({ temporal_refs: [{ raw: "April 20 to May 1", type: "range", resolved: "2026-04-20", resolved_end: "2026-05-01" }] }), + "temporal_classes", + ], + ])("detects capability %s", (_name, doc, capability) => { + const result = finalizeExtraction(doc, { produced_by: "test://model" }); + expect(result.extraction.capabilities).toContain(capability); + }); + + test("warns on mismatched capabilities hint", () => { + const result = finalizeExtraction(llmOutput(), { + produced_by: "test://model", + capabilities_hint: ["relations"], + }); + expect(result.warnings.some((warning) => warning.includes("relations"))).toBe(true); + }); + + test("warns on goal entity refs without entity_ids", () => { + const result = finalizeExtraction( + llmOutput({ + entities: [{ name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: ["e1"] }], + }), + { produced_by: "test://model" }, + ); + expect(result.warnings.some((warning) => warning.includes("entity_ids"))).toBe(true); + }); + + test("reports dangling entity refs in validation result", () => { + const result = finalizeExtraction( + llmOutput({ + entities: [{ name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: ["e_missing"] }], + }), + { produced_by: "openai://gpt-4o-mini" }, + ); + expect(result.validation.valid).toBe(false); + expect(result.validation.errors.some((error) => error.path.includes("entity_refs"))).toBe(true); + }); + + test("reports malformed embeddings in validation result", () => { + const result = finalizeExtraction(llmOutput(), { + produced_by: "openai://gpt-4o-mini", + embeddings: [{ + vector: [0.1, 0.2], + model: "not-a-uri", + input: "source", + dimensions: 99, + }], + }); + expect(result.validation.valid).toBe(false); + expect(result.validation.errors.some((error) => error.path === "embeddings[0].model")).toBe(true); + expect(result.validation.errors.some((error) => error.path === "embeddings[0].dimensions")).toBe(true); + }); + + test("passes end-to-end finalization", () => { + const result = finalizeExtraction( + llmOutput({ + entities: [{ + id: "e1", + name: "Mom", + type: "person", + state: "recovering", + source: { snippet: "My mom is recovering" }, + signals: { confidence: 0.9 }, + }], + goals: [{ + text: "Full recovery", + status: "open", + entity_refs: ["e1"], + stated_at: "2026-04-20", + }], + facts: [{ text: "Had surgery April 20", source: { snippet: "surgery" } }], + summary: "Prayer for mom's recovery.", + sentiment: "hopeful", + }), + { + produced_by: "openai://gpt-4o-mini", + user_id: "user_123", + source_id: "prayer-001", + kind: "conversa/prayer", + extensions: { "conversa/prayer": { category: "Health" } }, + }, + ); + expect(result.validation.valid).toBe(true); + expect(result.extraction.version).toBe("1"); + expect(result.extraction.produced_by).toBe("openai://gpt-4o-mini"); + expect(result.extraction.capabilities).toContain("entities"); + expect(result.extraction.capabilities).toContain("evidence_anchoring"); + }); +}); + +describe("finalizeExtraction conformance fixtures", () => { + test("matches shared finalization fixtures", () => { + const cases = loadJson; + context: Record; + expected_valid: boolean; + expected_capabilities?: string[]; + expected_error_paths?: string[]; + }>>(CONFORMANCE_DIR, "finalize_cases.json"); + + for (const fixture of cases) { + const result = finalizeExtraction( + fixture.llm_output, + fixture.context as Parameters[1], + ); + expect(result.validation.valid, fixture.name).toBe(fixture.expected_valid); + if (fixture.expected_valid) { + expect(result.extraction.version, fixture.name).toBe("1"); + expect(result.extraction.capabilities).toEqual(expect.arrayContaining(fixture.expected_capabilities ?? [])); + } else { + for (const path of fixture.expected_error_paths ?? []) { + expect(result.validation.errors.some((error) => error.path === path), `${fixture.name}:${path}`).toBe(true); + } + } + } + }); +}); diff --git a/packages/ts/tests/test_prompt.ts b/packages/ts/tests/test_prompt.ts new file mode 100644 index 0000000..14b9df5 --- /dev/null +++ b/packages/ts/tests/test_prompt.ts @@ -0,0 +1,280 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { buildExtractionPrompt, resolveCapabilities } from "../src/prompt.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); +const CONFORMANCE_DIR = resolve(REPO_ROOT, "tests", "conformance"); +const PACKAGE_PROMPTS_DIR = resolve(import.meta.dirname, "..", "prompts"); +const SAMPLE_TEXT = "Please pray for my mom. She had surgery on April 20 and is recovering well."; + +function loadJson(...parts: string[]): T { + return JSON.parse(readFileSync(resolve(...parts), "utf-8")) as T; +} + +describe("resolveCapabilities", () => { + test("accepts explicit capabilities", () => { + expect(new Set(resolveCapabilities({ capabilities: ["entities", "goals", "themes"] }))) + .toEqual(new Set(["entities", "goals", "themes"])); + }); + + test.each([ + ["minimal", ["entities", "entity_state", "goals", "themes", "summary"], ["relations"]], + ["standard", ["entities", "entity_context", "goal_timing", "facts", "temporal_refs", "sentiment", "evidence_anchoring"], ["relations"]], + ["full", ["entity_ids", "goal_entity_refs", "relations", "relation_origin", "assertion_signals", "temporal_classes"], []], + ])("resolves %s profile", (profile, expectedIncluded, expectedExcluded) => { + const result = resolveCapabilities({ profile: profile as "minimal" | "standard" | "full" }); + for (const capability of expectedIncluded) { + expect(result).toContain(capability); + } + for (const capability of expectedExcluded) { + expect(result).not.toContain(capability); + } + }); + + test.each([ + [{ profile: "minimal", add: ["relations"] }, ["entities", "relations", "entity_ids"]], + [{ profile: "standard", remove: ["sentiment"] }, ["entities"], ["sentiment"]], + [{ profile: "standard", add: ["relations"], remove: ["sentiment"] }, ["relations", "entity_ids"], ["sentiment"]], + ])("supports profile modifiers %#", (options, expectedIncluded, expectedExcluded = []) => { + const result = resolveCapabilities(options as Parameters[0]); + for (const capability of expectedIncluded) { + expect(result).toContain(capability); + } + for (const capability of expectedExcluded) { + expect(result).not.toContain(capability); + } + }); + + test("rejects unknown profile", () => { + expect(() => resolveCapabilities({ profile: "psychic" as never })).toThrow(/Unknown profile/); + }); + + test("rejects missing capabilities and profile", () => { + expect(() => resolveCapabilities({})).toThrow(); + }); + + test.each([ + [{ capabilities: ["bogus"] }, /Unknown capabilities/], + [{ capabilities: ["entities"], add: ["psychic"] }, /Unknown capabilities/], + [{ capabilities: ["bogus", "fake", "entities"] }, /bogus.*fake|fake.*bogus/], + [{ capabilities: ["entities"], remove: ["entities"] }, /empty/], + [{ capabilities: ["assertion_signals"] }, /base capability/], + [{ capabilities: ["evidence_anchoring"] }, /base capability/], + ])("rejects invalid capability inputs %#", (options, pattern) => { + expect(() => resolveCapabilities(options as Parameters[0])).toThrow(pattern); + }); + + test.each([ + [{ capabilities: ["entities", "assertion_signals"] }, ["entities", "assertion_signals"]], + [{ capabilities: ["facts", "evidence_anchoring"] }, ["facts", "evidence_anchoring"]], + [{ capabilities: ["entity_state"] }, ["entities", "entity_state"]], + [{ capabilities: ["entity_context"] }, ["entities", "entity_context"]], + [{ capabilities: ["entity_ids"] }, ["entities", "entity_ids"]], + [{ capabilities: ["goal_timing"] }, ["goals", "goal_timing"]], + [{ capabilities: ["goal_entity_refs"] }, ["goals", "entity_ids", "entities", "goal_entity_refs"]], + [{ capabilities: ["temporal_classes"] }, ["temporal_refs", "temporal_classes"]], + [{ capabilities: ["relations"] }, ["entities", "entity_ids", "relations"]], + [{ capabilities: ["relation_origin"] }, ["entities", "entity_ids", "relations", "relation_origin"]], + ])("applies dependency closure %#", (options, expectedIncluded) => { + const result = resolveCapabilities(options as Parameters[0]); + for (const capability of expectedIncluded) { + expect(result).toContain(capability); + } + }); +}); + +describe("buildExtractionPrompt", () => { + test("returns a non-empty string", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { profile: "minimal" }); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + test("contains text, extracted_at, and JSON instruction", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { profile: "minimal" }); + expect(result).toContain(SAMPLE_TEXT); + expect(result).toContain("extracted_at"); + expect(result).toContain("JSON"); + }); + + test.each([ + [{ capabilities: ["entities"] }, ['"entities"', '"name"', '"type"']], + [{ capabilities: ["entities", "entity_state"] }, ['"state"']], + [{ capabilities: ["goals"] }, ['"goals"', '"status"']], + [{ capabilities: ["themes"] }, ['"themes"']], + [{ capabilities: ["summary"] }, ['"summary"']], + [{ capabilities: ["sentiment"] }, ['"sentiment"']], + [{ capabilities: ["facts"] }, ['"facts"']], + [{ capabilities: ["temporal_refs"] }, ['"temporal_refs"']], + [{ capabilities: ["entities", "entity_ids", "relations"] }, ['"relations"']], + [{ capabilities: ["entities", "assertion_signals"] }, ['signals']], + [{ capabilities: ["entities", "evidence_anchoring"] }, ['source']], + ])("includes expected fragments %#", (options, snippets) => { + const result = buildExtractionPrompt(SAMPLE_TEXT, options as Parameters[1]); + for (const snippet of snippets) { + expect(result).toContain(snippet); + } + }); + + test("excludes absent capability fragments", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { capabilities: ["entities"] }); + expect(result).not.toContain('"relations"'); + expect(result).not.toContain('"sentiment"'); + }); + + test("includes categories, source_type, and date", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { + profile: "minimal", + categories: ["Health", "Family"], + source_type: "prayer", + date: "2026-04-25", + }); + expect(result).toContain("Health"); + expect(result).toContain("Family"); + expect(result).toContain("prayer"); + expect(result).toContain("2026-04-25"); + }); + + test("uses date in temporal resolution prompt", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { + capabilities: ["temporal_refs"], + date: "2026-04-25", + }); + expect(result).toContain("2026-04-25"); + }); + + test.each([ + ["minimal", false, false], + ["standard", false, true], + ["full", true, true], + ])("profile composition for %s", (profile, expectsRelations, expectsFacts) => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { profile: profile as "minimal" | "standard" | "full" }); + if (expectsRelations) { + expect(result).toContain('"relations"'); + } else { + expect(result).not.toContain('"relations"'); + } + if (expectsFacts) { + expect(result).toContain('"facts"'); + } + }); + + test("rejects specifying both profile and capabilities", () => { + expect(() => buildExtractionPrompt(SAMPLE_TEXT, { + profile: "minimal", + capabilities: ["entities"], + })).toThrow(); + }); + + test("prevents template injection in categories and source_type", () => { + const categoryResult = buildExtractionPrompt("hello", { + capabilities: ["entities"], + categories: ["A{{text}}B"], + }); + expect(categoryResult).toContain("A{{text}}B"); + expect(categoryResult).not.toContain("AhelloB"); + + const sourceTypeResult = buildExtractionPrompt("hello", { + capabilities: ["entities"], + source_type: "{{date}}", + }); + expect(sourceTypeResult).toContain("{{date}}"); + }); + + test("keeps preamble before fragments and text at the end", () => { + const result = buildExtractionPrompt(SAMPLE_TEXT, { profile: "full" }); + expect(result.indexOf("Extract structured data")).toBeLessThan(result.indexOf('"entities"')); + expect(result.indexOf("Rules:")).toBeLessThan(result.lastIndexOf(SAMPLE_TEXT)); + expect(result.indexOf('"entities"')).toBeLessThan(result.indexOf('"id"')); + }); +}); + +describe("prompt assets", () => { + test("all fragment files exist and are non-empty", () => { + const expected = [ + "preamble.txt", "postamble.txt", + "entities.txt", "entity_state.txt", "entity_context.txt", "entity_ids.txt", + "goals.txt", "goal_timing.txt", "goal_entity_refs.txt", + "themes.txt", "summary.txt", "sentiment.txt", "facts.txt", + "temporal_refs.txt", "temporal_classes.txt", + "relations.txt", "relation_origin.txt", + "assertion_signals.txt", "evidence_anchoring.txt", + ]; + + for (const name of expected) { + const content = readFileSync(resolve(PACKAGE_PROMPTS_DIR, "v1", name), "utf-8").trim(); + expect(content.length, name).toBeGreaterThan(0); + } + }); + + test("profile files exist and contain the expected capability sets", () => { + for (const name of ["minimal", "standard", "full"]) { + const data = loadJson<{ capabilities: string[] }>(PACKAGE_PROMPTS_DIR, "profiles", `${name}.json`); + expect(Array.isArray(data.capabilities), name).toBe(true); + } + + const minimal = new Set(loadJson<{ capabilities: string[] }>(PACKAGE_PROMPTS_DIR, "profiles", "minimal.json").capabilities); + expect(minimal).toEqual(new Set(["entities", "entity_state", "goals", "themes", "summary"])); + + const standard = new Set(loadJson<{ capabilities: string[] }>(PACKAGE_PROMPTS_DIR, "profiles", "standard.json").capabilities); + for (const capability of [ + "entities", + "entity_context", + "goal_timing", + "facts", + "temporal_refs", + "sentiment", + "evidence_anchoring", + ]) { + expect(standard.has(capability), capability).toBe(true); + } + + const full = new Set(loadJson<{ capabilities: string[] }>(PACKAGE_PROMPTS_DIR, "profiles", "full.json").capabilities); + for (const capability of standard) { + expect(full.has(capability), capability).toBe(true); + } + expect(full).toEqual(new Set([ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", "facts", + "temporal_refs", "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring", + ])); + }); +}); + +describe("prompt conformance fixtures", () => { + test("matches shared prompt fixtures", () => { + const cases = loadJson; + expected_capabilities: string[]; + prompt_includes: string[]; + prompt_excludes: string[]; + }>>(CONFORMANCE_DIR, "prompt_cases.json"); + + for (const fixture of cases) { + const resolveOptions = Object.fromEntries( + Object.entries(fixture.options).filter(([key]) => ["capabilities", "profile", "add", "remove"].includes(key)), + ); + const resolved = resolveCapabilities(resolveOptions as Parameters[0]); + expect(resolved, fixture.name).toEqual(fixture.expected_capabilities); + + const prompt = buildExtractionPrompt( + fixture.text, + fixture.options as Parameters[1], + ); + for (const snippet of fixture.prompt_includes) { + expect(prompt.includes(snippet), `${fixture.name}:${snippet}`).toBe(true); + } + for (const snippet of fixture.prompt_excludes) { + expect(prompt.includes(snippet), `${fixture.name}:${snippet}`).toBe(false); + } + } + }); +}); diff --git a/packages/ts/tests/test_validate.ts b/packages/ts/tests/test_validate.ts new file mode 100644 index 0000000..b7e4e66 --- /dev/null +++ b/packages/ts/tests/test_validate.ts @@ -0,0 +1,622 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { describe, expect, test } from "vitest"; + +import { validateExtraction } from "../src/validate.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", ".."); +const CONFORMANCE_DIR = resolve(REPO_ROOT, "tests", "conformance"); +const SCHEMAS_DIR = resolve(REPO_ROOT, "schemas"); +const ALL_CAPABILITIES = [ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", "facts", + "temporal_refs", "temporal_classes", + "relations", "relation_origin", + "assertion_signals", "evidence_anchoring", +]; + +function loadJson(...parts: string[]): T { + return JSON.parse(readFileSync(resolve(...parts), "utf-8")) as T; +} + +function minimalExtraction(overrides: Record = {}): Record { + return { + version: "1", + extracted_at: "2026-04-26T00:00:00Z", + produced_by: "openai://gpt-4o-mini", + entities: [], + goals: [], + themes: [], + capabilities: ["entities", "goals", "themes"], + ...overrides, + }; +} + +describe("validateExtraction", () => { + test("accepts a minimal extraction", () => { + const result = validateExtraction(minimalExtraction()); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test("accepts a full extraction", () => { + const result = validateExtraction(minimalExtraction({ + entities: [ + { + id: "e1", + name: "Mom", + type: "person", + state: "recovering", + context: "family member", + date_hint: "2026-04-20", + source: { version: "1", snippet: "My mom is recovering" }, + signals: { version: "1", confidence: 0.9, negated: false }, + relations: [{ target: "e2", type: "parent_of" }], + }, + { + id: "e2", + name: "Surgery", + type: "event", + }, + ], + goals: [ + { + text: "Mom's full recovery", + status: "open", + entity_refs: ["e1"], + stated_at: "2026-04-20T10:00:00Z", + source: { version: "1", snippet: "I hope mom recovers" }, + signals: { version: "1", hedged: true }, + }, + ], + themes: ["Health", "Family"], + summary: "Prayer for mom's recovery after surgery.", + sentiment: "hopeful", + facts: [ + { + text: "Mom had surgery on April 20", + category: "Health", + source: { version: "1", snippet: "Mom had surgery" }, + }, + ], + temporal_refs: [ + { + version: "1", + raw: "April 20", + type: "point", + resolved: "2026-04-20", + }, + ], + capabilities: [ + "entities", "entity_state", "entity_context", "entity_ids", + "goals", "goal_timing", "goal_entity_refs", + "themes", "summary", "sentiment", "facts", + "temporal_refs", "temporal_classes", + "relations", "assertion_signals", "evidence_anchoring", + ], + })); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test.each([ + ["version"], + ["extracted_at"], + ["produced_by"], + ["entities"], + ["goals"], + ["themes"], + ["capabilities"], + ])("rejects missing required field %s", (field) => { + const doc = minimalExtraction(); + delete doc[field]; + const result = validateExtraction(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === field)).toBe(true); + }); + + test("rejects non-object input", () => { + const result = validateExtraction("not an object"); + expect(result.valid).toBe(false); + expect(result.errors[0]?.message).toBe("must be an object"); + }); + + test("rejects null input", () => { + const result = validateExtraction(null); + expect(result.valid).toBe(false); + }); + + test.each([ + [ + "entity missing name", + minimalExtraction({ entities: [{ type: "person" }] }), + "entities[0].name", + ], + [ + "entity missing type", + minimalExtraction({ entities: [{ name: "Mom" }] }), + "entities[0].type", + ], + [ + "entity bad source version", + minimalExtraction({ + entities: [{ name: "Mom", type: "person", source: { version: "2", snippet: "test" } }], + }), + "entities[0].source.version", + ], + [ + "entity bad confidence", + minimalExtraction({ + entities: [{ name: "Mom", type: "person", signals: { version: "1", confidence: 1.5 } }], + }), + "entities[0].signals.confidence", + ], + [ + "entity relation missing target", + minimalExtraction({ + entities: [{ name: "Mom", type: "person", relations: [{ type: "knows" }] }], + }), + "entities[0].relations[0].target", + ], + ])("%s", (_name, doc, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === path)).toBe(true); + }); + + test.each([ + [ + "goal missing text", + minimalExtraction({ goals: [{ status: "open", entity_refs: [] }] }), + "goals[0].text", + ], + [ + "goal invalid status", + minimalExtraction({ goals: [{ text: "recover", status: "pending", entity_refs: [] }] }), + "goals[0].status", + ], + [ + "goal missing entity_refs", + minimalExtraction({ goals: [{ text: "recover", status: "open" }] }), + "goals[0].entity_refs", + ], + ])("%s", (_name, doc, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === path)).toBe(true); + }); + + test("rejects unknown capability", () => { + const result = validateExtraction(minimalExtraction({ + capabilities: ["entities", "psychic_powers"], + })); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.message.includes("psychic_powers"))).toBe(true); + }); + + test("accepts all valid capabilities", () => { + const result = validateExtraction(minimalExtraction({ capabilities: ALL_CAPABILITIES })); + expect(result.valid).toBe(true); + }); + + test.each([ + [ + "valid embedding", + minimalExtraction({ + embeddings: [{ + version: "1", + vector: [0.1, 0.2, 0.3], + model: "openai://text-embedding-3-small", + input: "source", + dimensions: 3, + }], + }), + true, + [], + ], + [ + "embedding missing vector", + minimalExtraction({ + embeddings: [{ + version: "1", + model: "openai://text-embedding-3-small", + input: "source", + dimensions: 3, + }], + }), + false, + ["embeddings[0].vector"], + ], + [ + "embedding dimensions mismatch", + minimalExtraction({ + embeddings: [{ + version: "1", + vector: [0.1, 0.2], + model: "openai://text-embedding-3-small", + input: "source", + dimensions: 99, + }], + }), + false, + ["embeddings[0].dimensions"], + ], + [ + "embedding model requires scheme", + minimalExtraction({ + embeddings: [{ + version: "1", + vector: [0.1, 0.2], + model: "text-embedding-3-small", + input: "source", + dimensions: 2, + }], + }), + false, + ["embeddings[0].model"], + ], + ])("%s", (_name, doc, expectedValid, paths) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(expectedValid); + for (const path of paths) { + expect(result.errors.some((error) => error.path === path)).toBe(true); + } + }); + + test.each([ + [ + "valid temporal ref", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "next Tuesday", type: "point", resolved: "2026-04-28" }], + }), + true, + [], + ], + [ + "invalid temporal type", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "sometime", type: "vague" }], + }), + false, + ["temporal_refs[0].type"], + ], + [ + "range without resolved_end", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "April 20 to May 1", type: "range", resolved: "2026-04-20" }], + }), + false, + ["temporal_refs[0].resolved_end"], + ], + [ + "unresolved with resolved", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "someday", type: "unresolved", resolved: "2026-04-20" }], + }), + false, + ["temporal_refs[0].resolved"], + ], + [ + "unresolved with resolved_end", + minimalExtraction({ + temporal_refs: [{ version: "1", raw: "someday", type: "unresolved", resolved_end: "2026-05-01" }], + }), + false, + ["temporal_refs[0].resolved_end"], + ], + ])("%s", (_name, doc, expectedValid, paths) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(expectedValid); + for (const path of paths) { + expect(result.errors.some((error) => error.path === path)).toBe(true); + } + }); + + test.each([ + ["gpt-4o-mini", false], + ["openai://gpt-4o-mini", true], + ["anthropic://claude-sonnet-4-20250514", true], + ["", false], + ])("validates produced_by format: %s", (producedBy, expectedValid) => { + const result = validateExtraction(minimalExtraction({ produced_by: producedBy })); + expect(result.valid).toBe(expectedValid); + }); + + test.each([ + ["entity name empty", minimalExtraction({ entities: [{ name: "", type: "person" }] }), "entities[0].name"], + ["entity type empty", minimalExtraction({ entities: [{ name: "Mom", type: "" }] }), "entities[0].type"], + ["goal text empty", minimalExtraction({ goals: [{ text: "", status: "open", entity_refs: [] }] }), "goals[0].text"], + ["theme empty", minimalExtraction({ themes: ["Health", ""] }), "themes[1]"], + ["fact text empty", minimalExtraction({ facts: [{ text: "" }] }), "facts[0].text"], + [ + "relation target empty", + minimalExtraction({ entities: [{ name: "Mom", type: "person", relations: [{ target: "", type: "knows" }] }] }), + "entities[0].relations[0].target", + ], + [ + "relation type empty", + minimalExtraction({ entities: [{ name: "Mom", type: "person", relations: [{ target: "e2", type: "" }] }] }), + "entities[0].relations[0].type", + ], + [ + "temporal raw empty", + minimalExtraction({ temporal_refs: [{ version: "1", raw: "" }] }), + "temporal_refs[0].raw", + ], + ["summary empty", minimalExtraction({ summary: "" }), "summary"], + ])("%s", (_name, doc, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === path)).toBe(true); + }); + + test.each([ + ["2026-04-26", true], + ["2026-04-26T10:30:00Z", true], + ["not-a-date", false], + ])("validates extracted_at shape: %s", (value, expectedValid) => { + const result = validateExtraction(minimalExtraction({ extracted_at: value })); + expect(result.valid).toBe(expectedValid); + }); + + test.each([ + [ + "goal stated_at bad", + minimalExtraction({ goals: [{ text: "Recovery", status: "open", entity_refs: [], stated_at: "not-a-date" }] }), + "goals[0].stated_at", + ], + [ + "goal resolved_at bad", + minimalExtraction({ goals: [{ text: "Recovery", status: "resolved", entity_refs: [], resolved_at: "whenever" }] }), + "goals[0].resolved_at", + ], + [ + "temporal resolved bad", + minimalExtraction({ temporal_refs: [{ version: "1", raw: "next week", type: "point", resolved: "not-a-date" }] }), + "temporal_refs[0].resolved", + ], + ])("%s", (_name, doc, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === path)).toBe(true); + }); + + test.each([ + [ + "source version only rejected", + minimalExtraction({ entities: [{ name: "Mom", type: "person", source: { version: "1" } }] }), + false, + ], + [ + "signals version only rejected", + minimalExtraction({ entities: [{ name: "Mom", type: "person", signals: { version: "1" } }] }), + false, + ], + [ + "source with snippet accepted", + minimalExtraction({ entities: [{ name: "Mom", type: "person", source: { version: "1", snippet: "My mom" } }] }), + true, + ], + [ + "signals with confidence accepted", + minimalExtraction({ entities: [{ name: "Mom", type: "person", signals: { version: "1", confidence: 0.9 } }] }), + true, + ], + [ + "goal source version only rejected", + minimalExtraction({ goals: [{ text: "Recovery", status: "open", entity_refs: [], source: { version: "1" } }] }), + false, + ], + [ + "fact signals version only rejected", + minimalExtraction({ facts: [{ text: "Surgery happened", signals: { version: "1" } }] }), + false, + ], + ])("%s", (_name, doc, expectedValid) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(expectedValid); + }); + + test.each([ + [ + "goal refs missing id", + minimalExtraction({ + entities: [{ name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: ["e1"] }], + }), + false, + "goals[0].entity_refs[0]", + ], + [ + "goal refs valid id", + minimalExtraction({ + entities: [{ id: "e1", name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: ["e1"] }], + }), + true, + "", + ], + [ + "relation target missing entity", + minimalExtraction({ + entities: [{ id: "e1", name: "Mom", type: "person", relations: [{ target: "e99", type: "knows" }] }], + }), + false, + "entities[0].relations[0].target", + ], + [ + "relation target valid entity", + minimalExtraction({ + entities: [ + { id: "e1", name: "Mom", type: "person", relations: [{ target: "e2", type: "parent_of" }] }, + { id: "e2", name: "Dad", type: "person" }, + ], + }), + true, + "", + ], + [ + "empty entity refs accepted", + minimalExtraction({ goals: [{ text: "Recovery", status: "open", entity_refs: [] }] }), + true, + "", + ], + ])("%s", (_name, doc, expectedValid, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(expectedValid); + if (path) { + expect(result.errors.some((error) => error.path === path)).toBe(true); + } + }); + + test.each([ + ["bad extension key", minimalExtraction({ extensions: { badkey: { foo: "bar" } } }), false, "extensions.badkey"], + ["good extension key", minimalExtraction({ extensions: { "conversa/prayer": { category: "Health" } } }), true, ""], + ["bad kind", minimalExtraction({ kind: "badkind" }), false, "kind"], + ["good kind", minimalExtraction({ kind: "conversa/prayer" }), true, ""], + ])("%s", (_name, doc, expectedValid, path) => { + const result = validateExtraction(doc); + expect(result.valid).toBe(expectedValid); + if (path) { + expect(result.errors.some((error) => error.path === path)).toBe(true); + } + }); +}); + +describe("validateExtraction conformance fixtures", () => { + test("matches shared validation fixtures", () => { + const cases = loadJson; + expected_valid: boolean; + expected_error_paths: string[]; + }>>(CONFORMANCE_DIR, "validate_cases.json"); + + for (const fixture of cases) { + const result = validateExtraction(fixture.input); + expect(result.valid, fixture.name).toBe(fixture.expected_valid); + for (const path of fixture.expected_error_paths) { + expect(result.errors.some((error) => error.path === path), `${fixture.name}:${path}`).toBe(true); + } + } + }); +}); + +describe("JSON Schema dereference", () => { + test("schema files are valid JSON with ids", () => { + const files = [ + resolve(SCHEMAS_DIR, "assertion-signals", "v1.json"), + resolve(SCHEMAS_DIR, "embedding", "v1.json"), + resolve(SCHEMAS_DIR, "extract", "v1.json"), + resolve(SCHEMAS_DIR, "source-ref", "v1.json"), + resolve(SCHEMAS_DIR, "temporal-ref", "v1.json"), + ]; + + for (const file of files) { + const schema = loadJson>(file); + expect(schema.$schema, file).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(typeof schema.$id, file).toBe("string"); + } + }); + + test("extraction schema references the expected sub-schemas", () => { + const schema = JSON.stringify(loadJson>(resolve(SCHEMAS_DIR, "extract", "v1.json"))); + expect(schema).toContain("source-ref/v1.json"); + expect(schema).toContain("embedding/v1.json"); + expect(schema).toContain("assertion-signals/v1.json"); + expect(schema).toContain("temporal-ref/v1.json"); + }); + + test("extraction schema carries the expected required fields", () => { + const schema = loadJson<{ required: string[] }>(resolve(SCHEMAS_DIR, "extract", "v1.json")); + expect(schema.required).toEqual(expect.arrayContaining([ + "version", + "extracted_at", + "produced_by", + "entities", + "goals", + "themes", + "capabilities", + ])); + }); + + test("ajv resolves hosted schema refs and matches validator on aligned structural cases", () => { + const ajv = new Ajv2020({ strict: false, allErrors: true }); + addFormats(ajv); + + const schemaFiles = [ + resolve(SCHEMAS_DIR, "assertion-signals", "v1.json"), + resolve(SCHEMAS_DIR, "embedding", "v1.json"), + resolve(SCHEMAS_DIR, "source-ref", "v1.json"), + resolve(SCHEMAS_DIR, "temporal-ref", "v1.json"), + resolve(SCHEMAS_DIR, "extract", "v1.json"), + ]; + + for (const file of schemaFiles) { + const schema = loadJson>(file); + ajv.addSchema(schema, schema.$id as string); + } + + const validateSchema = ajv.getSchema("https://synapt.dev/schemas/extract/v1.json"); + expect(validateSchema).toBeTypeOf("function"); + + const cases = [ + minimalExtraction(), + minimalExtraction({ version: "2" }), + minimalExtraction({ + entities: [{ id: "e1", name: "Mom", type: "person" }], + goals: [{ text: "Recovery", status: "open", entity_refs: ["e1"] }], + }), + ]; + + for (const doc of cases) { + const schemaValid = validateSchema!(doc); + const validatorValid = validateExtraction(doc).valid; + expect(schemaValid).toBe(validatorValid); + } + }); + + test("produced_by URI semantics stay aligned with the hosted schema", () => { + const ajv = new Ajv2020({ strict: false, allErrors: true }); + addFormats(ajv); + for (const file of [ + resolve(SCHEMAS_DIR, "assertion-signals", "v1.json"), + resolve(SCHEMAS_DIR, "embedding", "v1.json"), + resolve(SCHEMAS_DIR, "source-ref", "v1.json"), + resolve(SCHEMAS_DIR, "temporal-ref", "v1.json"), + resolve(SCHEMAS_DIR, "extract", "v1.json"), + ]) { + const schema = loadJson>(file); + ajv.addSchema(schema, schema.$id as string); + } + + const doc = minimalExtraction({ produced_by: "bad-model" }); + const schemaValid = ajv.getSchema("https://synapt.dev/schemas/extract/v1.json")!(doc); + const validatorValid = validateExtraction(doc).valid; + expect(schemaValid).toBe(validatorValid); + }); + + test("date-only extracted_at semantics stay aligned with the hosted schema", () => { + const ajv = new Ajv2020({ strict: false, allErrors: true }); + addFormats(ajv); + for (const file of [ + resolve(SCHEMAS_DIR, "assertion-signals", "v1.json"), + resolve(SCHEMAS_DIR, "embedding", "v1.json"), + resolve(SCHEMAS_DIR, "source-ref", "v1.json"), + resolve(SCHEMAS_DIR, "temporal-ref", "v1.json"), + resolve(SCHEMAS_DIR, "extract", "v1.json"), + ]) { + const schema = loadJson>(file); + ajv.addSchema(schema, schema.$id as string); + } + + const doc = minimalExtraction({ extracted_at: "2026-04-26" }); + const schemaValid = ajv.getSchema("https://synapt.dev/schemas/extract/v1.json")!(doc); + const validatorValid = validateExtraction(doc).valid; + expect(schemaValid).toBe(validatorValid); + }); +}); diff --git a/packages/ts/vitest.config.ts b/packages/ts/vitest.config.ts new file mode 100644 index 0000000..fdbbc56 --- /dev/null +++ b/packages/ts/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.ts"], + }, +}); diff --git a/tests/conformance/finalize_cases.json b/tests/conformance/finalize_cases.json new file mode 100644 index 0000000..5af326a --- /dev/null +++ b/tests/conformance/finalize_cases.json @@ -0,0 +1,100 @@ +[ + { + "name": "happy-path-finalize", + "llm_output": { + "extracted_at": "2026-04-26T00:00:00Z", + "entities": [ + { + "id": "e1", + "name": "Mom", + "type": "person", + "state": "recovering", + "source": { + "snippet": "My mom is recovering" + }, + "signals": { + "confidence": 0.9 + } + } + ], + "goals": [ + { + "text": "Full recovery", + "status": "open", + "entity_refs": ["e1"], + "stated_at": "2026-04-20" + } + ], + "themes": ["Health"], + "facts": [ + { + "text": "Mom had surgery", + "source": { + "snippet": "Mom had surgery" + } + } + ], + "summary": "Prayer for mom's recovery.", + "sentiment": "hopeful" + }, + "context": { + "produced_by": "openai://gpt-4o-mini", + "user_id": "user_123", + "source_id": "prayer-001", + "kind": "conversa/prayer", + "extensions": { + "conversa/prayer": { + "category": "Health" + } + } + }, + "expected_valid": true, + "expected_capabilities": [ + "entities", + "entity_state", + "entity_ids", + "goals", + "goal_timing", + "goal_entity_refs", + "themes", + "summary", + "sentiment", + "facts", + "assertion_signals", + "evidence_anchoring" + ] + }, + { + "name": "invalid-embedding-validation", + "llm_output": { + "extracted_at": "2026-04-26T00:00:00Z", + "entities": [ + { + "name": "Mom", + "type": "person" + } + ], + "goals": [ + { + "text": "Recovery", + "status": "open", + "entity_refs": [] + } + ], + "themes": ["Health"] + }, + "context": { + "produced_by": "openai://gpt-4o-mini", + "embeddings": [ + { + "vector": [0.1, 0.2], + "model": "not-a-uri", + "input": "source", + "dimensions": 99 + } + ] + }, + "expected_valid": false, + "expected_error_paths": ["embeddings[0].model", "embeddings[0].dimensions"] + } +] diff --git a/tests/conformance/prompt_cases.json b/tests/conformance/prompt_cases.json new file mode 100644 index 0000000..c0b9e7a --- /dev/null +++ b/tests/conformance/prompt_cases.json @@ -0,0 +1,37 @@ +[ + { + "name": "standard-profile", + "text": "Please pray for my mom. She had surgery on April 20 and is recovering well.", + "options": { + "profile": "standard", + "categories": ["Health", "Family"], + "source_type": "prayer", + "date": "2026-04-25" + }, + "expected_capabilities": [ + "entities", + "goals", + "themes", + "summary", + "sentiment", + "facts", + "temporal_refs", + "entity_state", + "entity_context", + "goal_timing", + "evidence_anchoring" + ], + "prompt_includes": ["Health", "Family", "prayer", "2026-04-25", "\"facts\""], + "prompt_excludes": ["\"relations\""] + }, + { + "name": "dependency-closure", + "text": "Mom encouraged me.", + "options": { + "capabilities": ["relation_origin"] + }, + "expected_capabilities": ["entities", "entity_ids", "relations", "relation_origin"], + "prompt_includes": ["\"relations\"", "\"id\""], + "prompt_excludes": [] + } +] diff --git a/tests/conformance/validate_cases.json b/tests/conformance/validate_cases.json new file mode 100644 index 0000000..ecd46d5 --- /dev/null +++ b/tests/conformance/validate_cases.json @@ -0,0 +1,63 @@ +[ + { + "name": "minimal-valid", + "input": { + "version": "1", + "extracted_at": "2026-04-26T00:00:00Z", + "produced_by": "openai://gpt-4o-mini", + "entities": [], + "goals": [], + "themes": [], + "capabilities": ["entities", "goals", "themes"] + }, + "expected_valid": true, + "expected_error_paths": [] + }, + { + "name": "range-missing-resolved-end", + "input": { + "version": "1", + "extracted_at": "2026-04-26T00:00:00Z", + "produced_by": "openai://gpt-4o-mini", + "entities": [], + "goals": [], + "themes": [], + "capabilities": ["entities", "goals", "themes", "temporal_refs", "temporal_classes"], + "temporal_refs": [ + { + "version": "1", + "raw": "April 20 to May 1", + "type": "range", + "resolved": "2026-04-20" + } + ] + }, + "expected_valid": false, + "expected_error_paths": ["temporal_refs[0].resolved_end"] + }, + { + "name": "dangling-goal-entity-ref", + "input": { + "version": "1", + "extracted_at": "2026-04-26T00:00:00Z", + "produced_by": "openai://gpt-4o-mini", + "entities": [ + { + "name": "Mom", + "type": "person" + } + ], + "goals": [ + { + "text": "Recovery", + "status": "open", + "entity_refs": ["e1"] + } + ], + "themes": [], + "capabilities": ["entities", "goals", "themes", "goal_entity_refs"] + }, + "expected_valid": false, + "expected_error_paths": ["goals[0].entity_refs[0]"] + } +] diff --git a/tests/python/test_conformance.py b/tests/python/test_conformance.py new file mode 100644 index 0000000..be708b9 --- /dev/null +++ b/tests/python/test_conformance.py @@ -0,0 +1,65 @@ +"""Cross-language conformance fixtures shared by Python and TypeScript suites.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages" / "python" / "src")) + +from synapt_extract.finalize import finalize_extraction, FinalizeContext +from synapt_extract.prompt import build_extraction_prompt, resolve_capabilities +from synapt_extract.validate import validate_extraction + + +FIXTURES_DIR = Path(__file__).resolve().parents[1] / "conformance" + + +def _load(name: str): + return json.loads((FIXTURES_DIR / name).read_text()) + + +class TestValidationConformance: + + def test_validate_cases(self): + for case in _load("validate_cases.json"): + result = validate_extraction(case["input"]) + assert result.valid is case["expected_valid"], case["name"] + for path in case["expected_error_paths"]: + assert any(err.path == path for err in result.errors), case["name"] + + +class TestFinalizeConformance: + + def test_finalize_cases(self): + for case in _load("finalize_cases.json"): + result = finalize_extraction( + case["llm_output"], + FinalizeContext(**case["context"]), + ) + assert result.validation.valid is case["expected_valid"], case["name"] + if case["expected_valid"]: + assert result.extraction["version"] == "1" + assert set(case["expected_capabilities"]).issubset(set(result.extraction["capabilities"])) + else: + for path in case["expected_error_paths"]: + assert any(err.path == path for err in result.validation.errors), case["name"] + + +class TestPromptConformance: + + def test_prompt_cases(self): + for case in _load("prompt_cases.json"): + resolve_opts = { + key: case["options"][key] + for key in ("capabilities", "profile", "add", "remove") + if key in case["options"] + } + resolved = resolve_capabilities(**resolve_opts) + assert resolved == case["expected_capabilities"], case["name"] + prompt = build_extraction_prompt(case["text"], **case["options"]) + for snippet in case["prompt_includes"]: + assert snippet in prompt, case["name"] + for snippet in case["prompt_excludes"]: + assert snippet not in prompt, case["name"] From 3b7fcb3ade5ff559d7b87a94695d0f7365b1401a Mon Sep 17 00:00:00 2001 From: Layne Penney Date: Sun, 26 Apr 2026 11:16:35 -0500 Subject: [PATCH 12/12] test: align extracted_at parity expectation --- packages/ts/tests/test_validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ts/tests/test_validate.ts b/packages/ts/tests/test_validate.ts index b7e4e66..c4c1a7d 100644 --- a/packages/ts/tests/test_validate.ts +++ b/packages/ts/tests/test_validate.ts @@ -355,7 +355,7 @@ describe("validateExtraction", () => { }); test.each([ - ["2026-04-26", true], + ["2026-04-26", false], ["2026-04-26T10:30:00Z", true], ["not-a-date", false], ])("validates extracted_at shape: %s", (value, expectedValid) => {