feat: add ingest review flags and Jev scoring
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-14 20:35:48 +08:00
1 parent 1c61c7ad04
commit 7b2c43ddaf
18 files changed
+1648 -2

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
OPENROUTER_API_KEY=
OPENROUTER_JEV_MODEL=typesafe/jev-1.13
+4
View File
@@ -118,3 +118,7 @@ When adding GEWU into a user project folder that already has active agents, skil
### URL capture scope
The authenticated `source_capture_url` tool blocks all requests unless the service operator sets `GEWU_FETCH_ALLOWED_HOSTS` to exact comma-separated hostnames. It rejects private or reserved DNS answers, nonstandard ports, and redirects to hosts outside that list. Archive support includes UTF-8 text, Markdown, HTML visible-text extraction and PDF text extraction on Linux when bubblewrap, Poppler `pdftotext` and `prlimit` are installed. PDF parsing runs with no network, an empty environment, read-only system mounts and resource limits. Mixed text and blank pages produce a partial extraction; image-only/scanned PDFs report `PDF_NO_TEXT` as unavailable. Encrypted or unsupported PDFs report explicit extraction failure codes. Capture returns a stable source identity, a content-bound revision candidate, and a typed record to submit through `proposal_submit`; failed captures return an unarchived bookmark candidate. Other operating systems do not yet have a qualified PDF sandbox.
### Optional Jev relevance signal
The backend can call OpenRouter's Jev Decisions API when it records an ingest-review flag. Add `OPENROUTER_API_KEY` to the ignored `.env` file (`.env.example` is a template), then restart `gewu-api`. The app restricts `.env` permissions to the current user. It sends the project title, north star, key questions, candidate excerpt, summary and rationale to Jev for a relevance probability and research-lens suggestion. The score is advisory; unavailable Jev never blocks proposal submission. Keep the key on the server and out of browser or agent configuration. See OpenRouter's [Jev API guide](https://openrouter.ai/docs/guides/community/jev-tutorial) for API and billing details.
+44
View File
@@ -0,0 +1,44 @@
# §G
Broaden GEWU ingestion so every project considers technical, conceptual, cultural/historical, and financial/market material. Persist plausible but uncertain relevance as a review flag alongside normal ingestion, optionally assess relevance and lens with JEV, and let explicit project-scoped user feedback guide later ingestion without weakening evidence validation or silently changing project policy.
# §C
- The four research lenses are defaults to consider, not quotas; a source need not contain material for every lens.
- A plausible connection with uncertain relevance follows the normal proposal flow and also receives a durable user-review flag; the flag neither blocks nor auto-publishes the proposal.
- Exclude only clearly out-of-scope material, unsupported/unavailable source content, or work deferred for an explicit budget; retain section-level coverage and reasons.
- Evidence validity, source attribution, scope authorization, and proposal acceptance rules do not change.
- User-review flags are stored separately from proposal validation's existing `needs-review` status and protected owner decisions. They notify/filter for human feedback and do not control proposal acceptance.
- Feedback is scoped to one project. It may inform later triage only when the user explicitly chooses to save it as project guidance; it does not train the model or alter acceptance policy.
- Existing projects receive the four default lenses when read, while retaining their current questions and project-specific context.
- Optional Jev relevance/lens scoring is advisory; missing configuration or provider failure never blocks the normal ingest/proposal path.
# §I
- Project context exposes default lenses: `technical`, `conceptual`, `cultural-historical`, `financial-market`, plus optional project-specific lenses.
- Agent-to-service interface: create a bounded review flag containing project/run/source revision, extraction and exact section locator, bounded evidence excerpt, lens, concise candidate summary and relevance rationale. The agent still follows normal proposal evaluation/submission. Optionally attach backend JEV relevance/lens probabilities; its failure does not block ingestion. It must not accept arbitrary local paths or credentials.
- Human review interface: an authenticated GEWU web view lists items within authorized project scope; a human decides `include`, `exclude`, or `defer`; optionally saves a concise reusable guidance rule for that project. Decisions record reviewer, time, rationale, and item identity.
- Agent project context returns only approved project guidance derived from human review. Review-item content and guidance remain distinct from canonical knowledge proposals.
- Candidate names are provisional: MCP `ingest_review_create`; authenticated review read/decision routes; `get_project_context` returns `research_lenses` and approved `ingest_guidance`.
# §V
V1: Every ingest run considers all four default lenses unless the human project context explicitly narrows scope.
V2: Plausible but low-confidence relevance follows normal ingestion and creates a review flag with source-bound section locator and rationale; it is not silently dropped or held from proposal submission.
V3: A review item cannot become accepted knowledge without a separately validated proposal and the existing acceptance policy.
V4: User review decisions are attributable, idempotent, project-authorized, and immutable; corrections append a new decision.
V5: Only an explicit human choice promotes a review decision into reusable project guidance; guidance never overrides evidence, authorization, or acceptance checks.
V6: Every run reports selected, included, review-queued, excluded, and budget-deferred coverage with reasons.
V7: Review items and feedback from one project are never returned in another project's context without explicit authorized sharing.
V8: An optional Jev provider never becomes a source of evidence, an acceptance gate, or a requirement for storing review flags.
# §T
id|status|task|cites
T1|x|Define research-lens and ingest-review records, authorization, retention, and migration behavior|I,V3,V4,V7
T2|x|Add four default lenses to project creation/read/update and expose approved review guidance in `get_project_context`|I,V1,V5
T3|x|Revise `kb-ingest` and its pipeline to retain plausible cross-lens material for review and report complete dispositions|V1,V2,V6
T4|x|Add service-side review-flag creation, scoped listing, and authenticated human decisions, separate from proposal lifecycle and owner review|I,V3,V4,V7
T5|x|Add an explicit option to promote a human review decision into project-specific reusable guidance|I,V5
T7|x|Call optional OpenRouter Jev for relevance/lens probabilities and store advisory results without blocking ingestion|V2,V8
T6|.|Verify default lenses, review queue isolation, idempotent/attributable decisions, feedback application, no auto-publication, evidence integrity, and resume behavior|V1,V2,V3,V4,V5,V6,V7
+24
View File
@@ -219,6 +219,22 @@
"mcp"
]
},
{
"capability": "ingest:review",
"mutates_knowledge": false,
"name": "ingest_review_list",
"transports": [
"http"
]
},
{
"capability": "ingest:review",
"mutates_knowledge": false,
"name": "ingest_review_decide",
"transports": [
"http"
]
},
{
"capability": "knowledge:read",
"mutates_knowledge": false,
@@ -409,6 +425,14 @@
"mcp"
]
},
{
"capability": "ingest:review",
"mutates_knowledge": false,
"name": "ingest_review_create",
"transports": [
"mcp"
]
},
{
"capability": "owner:review",
"mutates_knowledge": false,
+505
View File
@@ -1131,6 +1131,249 @@
"title": "HTTPValidationError",
"type": "object"
},
"IngestReviewDecision": {
"additionalProperties": false,
"properties": {
"decision": {
"enum": [
"include",
"exclude",
"defer"
],
"title": "Decision",
"type": "string"
},
"decision_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Decision Id",
"type": "string"
},
"guidance_rule": {
"anyOf": [
{
"maxLength": 1000,
"type": "string"
},
{
"type": "null"
}
],
"title": "Guidance Rule"
},
"rationale": {
"maxLength": 2000,
"minLength": 1,
"title": "Rationale",
"type": "string"
},
"review_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Review Id",
"type": "string"
},
"save_as_guidance": {
"default": false,
"title": "Save As Guidance",
"type": "boolean"
},
"schema_version": {
"const": 1,
"default": 1,
"title": "Schema Version",
"type": "integer"
}
},
"required": [
"decision_id",
"review_id",
"decision",
"rationale"
],
"title": "IngestReviewDecision",
"type": "object"
},
"IngestReviewItem": {
"additionalProperties": false,
"properties": {
"created_at": {
"format": "date-time",
"title": "Created At",
"type": "string"
},
"excerpt": {
"maxLength": 4000,
"minLength": 1,
"title": "Excerpt",
"type": "string"
},
"extraction_id": {
"pattern": "^[a-f0-9]{64}$",
"title": "Extraction Id",
"type": "string"
},
"jev_lens": {
"anyOf": [
{
"enum": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"type": "string"
},
{
"type": "null"
}
],
"title": "Jev Lens"
},
"jev_lens_confidence": {
"anyOf": [
{
"maximum": 1.0,
"minimum": 0.0,
"type": "number"
},
{
"type": "null"
}
],
"title": "Jev Lens Confidence"
},
"jev_model": {
"anyOf": [
{
"maxLength": 100,
"type": "string"
},
{
"type": "null"
}
],
"title": "Jev Model"
},
"jev_relation_probability": {
"anyOf": [
{
"maximum": 1.0,
"minimum": 0.0,
"type": "number"
},
{
"type": "null"
}
],
"title": "Jev Relation Probability"
},
"lens": {
"enum": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"title": "Lens",
"type": "string"
},
"locator": {
"$ref": "#/components/schemas/Locator"
},
"project_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Project Id",
"type": "string"
},
"relevance": {
"default": "borderline",
"enum": [
"borderline",
"low"
],
"title": "Relevance",
"type": "string"
},
"relevance_rationale": {
"maxLength": 2000,
"minLength": 1,
"title": "Relevance Rationale",
"type": "string"
},
"review_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Review Id",
"type": "string"
},
"run_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Run Id",
"type": "string"
},
"schema_version": {
"const": 1,
"default": 1,
"title": "Schema Version",
"type": "integer"
},
"source_revision_id": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Source Revision Id",
"type": "string"
},
"status": {
"default": "needs-user-review",
"enum": [
"needs-user-review",
"included",
"excluded",
"deferred"
],
"title": "Status",
"type": "string"
},
"submitted_by": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Submitted By",
"type": "string"
},
"summary": {
"maxLength": 2000,
"minLength": 1,
"title": "Summary",
"type": "string"
}
},
"required": [
"review_id",
"project_id",
"run_id",
"source_revision_id",
"extraction_id",
"locator",
"excerpt",
"lens",
"summary",
"relevance_rationale",
"submitted_by",
"created_at"
],
"title": "IngestReviewItem",
"type": "object"
},
"IssuedCredential": {
"properties": {
"credential_id": {
@@ -2308,6 +2551,25 @@
"title": "Project Id",
"type": "string"
},
"research_lenses": {
"default": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"items": {
"enum": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"type": "string"
},
"title": "Research Lenses",
"type": "array"
},
"schema_version": {
"const": 1,
"default": 1,
@@ -8383,6 +8645,249 @@
]
}
},
"/v1/ingest-reviews": {
"get": {
"operationId": "ingest_review_list",
"parameters": [
{
"in": "query",
"name": "project_id",
"required": true,
"schema": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Project Id",
"type": "string"
}
},
{
"in": "query",
"name": "status",
"required": false,
"schema": {
"anyOf": [
{
"maxLength": 32,
"type": "string"
},
{
"type": "null"
}
],
"default": "needs-user-review",
"title": "Status"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
},
{
"in": "header",
"name": "X-GEWU-CSRF",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Gewu-Csrf"
}
},
{
"in": "cookie",
"name": "gewu_session",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Gewu Session"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/IngestReviewItem"
},
"title": "Response Ingest Review List",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "List Reviews",
"tags": [
"ingest review"
]
}
},
"/v1/ingest-reviews/{review_id}/decision": {
"post": {
"operationId": "ingest_review_decide",
"parameters": [
{
"in": "path",
"name": "review_id",
"required": true,
"schema": {
"maxLength": 128,
"minLength": 1,
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$",
"title": "Review Id",
"type": "string"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
},
{
"in": "header",
"name": "X-GEWU-CSRF",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Gewu-Csrf"
}
},
{
"in": "cookie",
"name": "gewu_session",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Gewu Session"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IngestReviewDecision"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"title": "Response Ingest Review Decide",
"type": "object"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Decide Review",
"tags": [
"ingest review"
]
}
},
"/v1/me": {
"get": {
"operationId": "account_current",
@@ -71,6 +71,25 @@
"title": "Project Id",
"type": "string"
},
"research_lenses": {
"default": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"items": {
"enum": [
"technical",
"conceptual",
"cultural-historical",
"financial-market"
],
"type": "string"
},
"title": "Research Lenses",
"type": "array"
},
"schema_version": {
"const": 1,
"default": 1,
+3
View File
@@ -12,6 +12,7 @@ from typing import Any
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_ingest_reviews import SQLiteIngestReviewStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.adapters.text_extractor import TextMarkdownExtractor
from gewu.application.acceptance import AcceptanceService
@@ -37,6 +38,7 @@ async def _tool_inventory(data_root: Path) -> list[dict[str, Any]]:
repository = GitCanonicalRepository(data_root / "knowledge.git")
identities = SQLiteIdentityStore(data_root / "identity.sqlite3")
artifact_store = LocalArtifactStore(data_root / "artifacts")
ingest_review_store = SQLiteIngestReviewStore(data_root / "ingest-reviews.sqlite3")
def context_provider() -> ValidationContext:
return ValidationContext({}, {}, {}, {}, {})
@@ -62,6 +64,7 @@ async def _tool_inventory(data_root: Path) -> list[dict[str, Any]]:
extractor=TextMarkdownExtractor(),
acceptance_service=acceptance_service,
context_provider=context_provider,
ingest_review_store=ingest_review_store,
)
tools = await server.list_tools()
return [
+125
View File
@@ -0,0 +1,125 @@
"""Small fixed-endpoint client for optional OpenRouter Jev relevance judgments."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, cast
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, Request, build_opener
from gewu.domain.contracts import IngestReviewCreate, ProjectRecord, ResearchLens
ENDPOINT = "https://openrouter.ai/api/alpha/decisions"
MODEL = "typesafe/jev-1.13"
MAX_RESPONSE_BYTES = 32_000
@dataclass(frozen=True, slots=True)
class JevAssessment:
relation_probability: float
lens: ResearchLens
lens_confidence: float
model: str
class JevUnavailable(RuntimeError):
"""Jev is not configured or returned an unusable response."""
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(
self, request: Any, response: Any, code: int, message: str, headers: Any, new_url: str
) -> None:
return None
class OpenRouterJev:
def __init__(self, api_key: str, *, model: str = MODEL) -> None:
if not api_key or len(api_key) > 512 or any(char.isspace() for char in api_key):
raise ValueError("OpenRouter API key is invalid")
if model not in {MODEL, "~typesafe/jev-latest"}:
raise ValueError("Unsupported Jev model identifier")
self._api_key = api_key
self._model = model
def assess(self, item: IngestReviewCreate, project: ProjectRecord) -> JevAssessment:
state = {
"project_title": project.title,
"project_north_star": project.north_star,
"project_questions": project.key_questions,
"candidate_lens": item.lens,
"candidate_summary": item.summary,
"candidate_relevance_rationale": item.relevance_rationale,
"source_excerpt": item.excerpt,
}
criteria = {
"technical": "Engineering, implementation, technology, or operational practice.",
"conceptual": "Ideas, theory, design concepts, or analytical frameworks.",
"cultural-historical": "Cultural, artistic, historical, social, or audience context.",
"financial-market": "Costs, revenues, business models, markets, or financing.",
}
payload = json.dumps(
{
"model": self._model,
"state": state,
"questions": {
"project_relation": {
"type": "noul",
"instructions": (
"Does this passage help answer a project question or explain "
"the project's technical, conceptual, cultural-historical, or "
"financial-market context? Treat indirect context as relevant when "
"the connection is plausible."
),
"criteria": {
"true": "A plausible, specific connection to the project exists.",
"false": "The passage has no plausible project connection.",
},
},
"best_lens": {
"type": "choice",
"instructions": "Which project research lens best describes this passage?",
"criteria": criteria,
},
},
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
request = Request(
ENDPOINT,
data=payload,
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with build_opener(_NoRedirect()).open(request, timeout=12) as response:
if response.status != 200:
raise JevUnavailable("Jev request failed")
raw = response.read(MAX_RESPONSE_BYTES + 1)
except (HTTPError, URLError, TimeoutError, OSError) as error:
raise JevUnavailable("Jev request failed") from error
if len(raw) > MAX_RESPONSE_BYTES:
raise JevUnavailable("Jev response exceeded its size limit")
try:
result = json.loads(raw)
answers = result["answers"]
relation = float(answers["project_relation"]["noul"])
lens = answers["best_lens"]["choice"]
confidence = float(answers["best_lens"]["confidence"])
served_model = str(result["model"])
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
raise JevUnavailable("Jev response did not match its typed decision schema") from error
if (
not 0 <= relation <= 1
or not 0 <= confidence <= 1
or lens not in criteria
or len(served_model) > 100
):
raise JevUnavailable("Jev response contained an invalid decision")
return JevAssessment(relation, cast(ResearchLens, lens), confidence, served_model)
+316
View File
@@ -0,0 +1,316 @@
"""Durable, project-scoped user review queue for borderline ingest material."""
from __future__ import annotations
import hashlib
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from gewu.application.paths import reject_symlink_components
from gewu.domain.contracts import (
IngestGuidance,
IngestReviewCreate,
IngestReviewDecision,
IngestReviewItem,
)
MAX_REVIEW_ITEMS = 100_000
MAX_REVIEW_PAGE = 100
class ReviewConflict(ValueError):
"""An idempotency key was reused with different content or state."""
class SQLiteIngestReviewStore:
"""Store review candidates and immutable human decisions outside canonical knowledge."""
def __init__(self, database: Path) -> None:
self.database = database.expanduser().absolute()
reject_symlink_components(self.database)
self.database.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.database.parent.chmod(0o700)
if self.database.is_symlink():
raise RuntimeError("Ingest review database must not be a symbolic link")
with self._connect() as connection:
connection.execute(
"""CREATE TABLE IF NOT EXISTS ingest_review_items (
review_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
run_id TEXT NOT NULL,
source_revision_id TEXT NOT NULL,
extraction_id TEXT NOT NULL,
locator_json TEXT NOT NULL,
excerpt TEXT NOT NULL,
lens TEXT NOT NULL,
summary TEXT NOT NULL,
relevance_rationale TEXT NOT NULL,
relevance TEXT NOT NULL CHECK(relevance IN ('borderline', 'low')),
jev_relation_probability REAL,
jev_lens TEXT,
jev_lens_confidence REAL,
jev_model TEXT,
status TEXT NOT NULL CHECK(status IN (
'needs-user-review', 'included', 'excluded', 'deferred'
)),
submitted_by TEXT NOT NULL,
created_at TEXT NOT NULL,
payload_digest TEXT NOT NULL
)"""
)
existing_columns = {
row[1] for row in connection.execute("PRAGMA table_info(ingest_review_items)")
}
for column, declaration in (
("jev_relation_probability", "REAL"),
("jev_lens", "TEXT"),
("jev_lens_confidence", "REAL"),
("jev_model", "TEXT"),
):
if column not in existing_columns:
connection.execute(
f"ALTER TABLE ingest_review_items ADD COLUMN {column} {declaration}"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS ingest_review_project_status_created "
"ON ingest_review_items(project_id, status, created_at DESC, review_id)"
)
connection.execute(
"""CREATE TABLE IF NOT EXISTS ingest_review_decisions (
decision_id TEXT PRIMARY KEY,
review_id TEXT NOT NULL REFERENCES ingest_review_items(review_id),
project_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK(decision IN ('include', 'exclude', 'defer')),
rationale TEXT NOT NULL,
save_as_guidance INTEGER NOT NULL CHECK(save_as_guidance IN (0, 1)),
guidance_rule TEXT,
reviewer_id TEXT NOT NULL,
created_at TEXT NOT NULL,
payload_digest TEXT NOT NULL
)"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS ingest_review_guidance "
"ON ingest_review_decisions(project_id, save_as_guidance, created_at DESC)"
)
connection.execute(
"""CREATE TRIGGER IF NOT EXISTS ingest_review_decisions_no_update
BEFORE UPDATE ON ingest_review_decisions BEGIN
SELECT RAISE(ABORT, 'review decisions are immutable');
END"""
)
connection.execute(
"""CREATE TRIGGER IF NOT EXISTS ingest_review_decisions_no_delete
BEFORE DELETE ON ingest_review_decisions BEGIN
SELECT RAISE(ABORT, 'review decisions are immutable');
END"""
)
self.database.chmod(0o600)
def create(
self,
item: IngestReviewCreate,
*,
submitted_by: str,
jev_relation_probability: float | None = None,
jev_lens: str | None = None,
jev_lens_confidence: float | None = None,
jev_model: str | None = None,
) -> IngestReviewItem:
payload = item.model_dump(mode="json")
digest = _digest({**payload, "submitted_by": submitted_by})
created_at = datetime.now(UTC).isoformat()
with self._connect() as connection:
row = connection.execute(
"SELECT payload_digest FROM ingest_review_items WHERE review_id=?",
(item.review_id,),
).fetchone()
if row is not None:
if row[0] != digest:
raise ReviewConflict("Review ID was already used with different content")
return self._read_item(connection, item.review_id)
count = connection.execute("SELECT count(*) FROM ingest_review_items").fetchone()[0]
if count >= MAX_REVIEW_ITEMS:
raise ValueError("Ingest review queue is at its configured capacity")
connection.execute(
"""INSERT INTO ingest_review_items (
review_id, project_id, run_id, source_revision_id, extraction_id,
locator_json, excerpt, lens, summary, relevance_rationale, relevance,
status, submitted_by, created_at, payload_digest,
jev_relation_probability, jev_lens, jev_lens_confidence, jev_model
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'needs-user-review', ?, ?, ?, ?, ?, ?, ?
)""",
(
item.review_id,
item.project_id,
item.run_id,
item.source_revision_id,
item.extraction_id,
item.locator.model_dump_json(),
item.excerpt,
item.lens,
item.summary,
item.relevance_rationale,
item.relevance,
submitted_by,
created_at,
digest,
jev_relation_probability,
jev_lens,
jev_lens_confidence,
jev_model,
),
)
return self._read_item(connection, item.review_id)
def list_items(
self, project_id: str, *, status: str | None, limit: int, offset: int
) -> list[IngestReviewItem]:
if not 1 <= limit <= MAX_REVIEW_PAGE or offset < 0:
raise ValueError("Review pagination is outside the supported range")
with self._connect() as connection:
if status is None:
rows = connection.execute(
"SELECT review_id FROM ingest_review_items WHERE project_id=? "
"ORDER BY created_at DESC, review_id LIMIT ? OFFSET ?",
(project_id, limit, offset),
).fetchall()
else:
rows = connection.execute(
"SELECT review_id FROM ingest_review_items WHERE project_id=? AND status=? "
"ORDER BY created_at DESC, review_id LIMIT ? OFFSET ?",
(project_id, status, limit, offset),
).fetchall()
return [self._read_item(connection, row[0]) for row in rows]
def get(self, review_id: str) -> IngestReviewItem | None:
with self._connect() as connection:
exists = connection.execute(
"SELECT 1 FROM ingest_review_items WHERE review_id=?", (review_id,)
).fetchone()
return self._read_item(connection, review_id) if exists else None
def decide(
self,
review_id: str,
decision: IngestReviewDecision,
*,
reviewer_id: str,
) -> IngestReviewItem:
now = datetime.now(UTC).isoformat()
decision_payload = decision.model_dump(mode="json")
digest = _digest({**decision_payload, "reviewer_id": reviewer_id})
with self._connect() as connection:
existing = connection.execute(
"SELECT payload_digest, review_id FROM ingest_review_decisions WHERE decision_id=?",
(decision.decision_id,),
).fetchone()
if existing is not None:
if existing[0] != digest or existing[1] != review_id:
raise ReviewConflict("Decision ID was already used with different content")
return self._read_item(connection, review_id)
row = connection.execute(
"SELECT project_id, status FROM ingest_review_items WHERE review_id=?", (review_id,)
).fetchone()
if row is None:
raise KeyError("Ingest review item is unavailable")
if row[1] != "needs-user-review":
raise ReviewConflict("Review item already has a human decision")
project_id = row[0]
connection.execute(
"""INSERT INTO ingest_review_decisions (
decision_id, review_id, project_id, decision, rationale,
save_as_guidance, guidance_rule, reviewer_id, created_at, payload_digest
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
decision.decision_id,
review_id,
project_id,
decision.decision,
decision.rationale,
int(decision.save_as_guidance),
decision.guidance_rule,
reviewer_id,
now,
digest,
),
)
connection.execute(
"UPDATE ingest_review_items SET status=? WHERE review_id=?",
(
{"include": "included", "exclude": "excluded", "defer": "deferred"}[
decision.decision
],
review_id,
),
)
return self._read_item(connection, review_id)
def guidance(self, project_id: str, *, limit: int = 100) -> tuple[IngestGuidance, ...]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT d.decision_id, i.lens, d.guidance_rule, d.created_at
FROM ingest_review_decisions d
JOIN ingest_review_items i USING(review_id)
WHERE d.project_id=? AND d.save_as_guidance=1
ORDER BY d.created_at DESC, d.decision_id LIMIT ?""",
(project_id, min(max(limit, 1), 100)),
).fetchall()
return tuple(
IngestGuidance(
guidance_id=row[0],
lens=row[1],
rule=row[2],
created_at=datetime.fromisoformat(row[3]),
)
for row in rows
)
def _read_item(self, connection: sqlite3.Connection, review_id: str) -> IngestReviewItem:
row = connection.execute(
"""SELECT review_id, project_id, run_id, source_revision_id, extraction_id,
locator_json, excerpt, lens, summary, relevance_rationale, relevance, status,
submitted_by, created_at, jev_relation_probability, jev_lens,
jev_lens_confidence, jev_model FROM ingest_review_items WHERE review_id=?""",
(review_id,),
).fetchone()
if row is None:
raise KeyError("Ingest review item is unavailable")
return IngestReviewItem.model_validate(
{
"review_id": row[0],
"project_id": row[1],
"run_id": row[2],
"source_revision_id": row[3],
"extraction_id": row[4],
"locator": json.loads(row[5]),
"excerpt": row[6],
"lens": row[7],
"summary": row[8],
"relevance_rationale": row[9],
"relevance": row[10],
"status": row[11],
"submitted_by": row[12],
"created_at": datetime.fromisoformat(row[13]),
"jev_relation_probability": row[14],
"jev_lens": row[15],
"jev_lens_confidence": row[16],
"jev_model": row[17],
},
strict=True,
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database, timeout=5, isolation_level="IMMEDIATE")
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=5000")
return connection
def _digest(value: object) -> str:
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+46
View File
@@ -14,8 +14,10 @@ from starlette.routing import Mount
from gewu.adapters.embeddings import LocalSentenceTransformer
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.openrouter_jev import OpenRouterJev
from gewu.adapters.source_extractor import LocalSourceExtractor, SourceExtractor
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_ingest_reviews import SQLiteIngestReviewStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
from gewu.adapters.url_fetch import PublicUrlFetcher
@@ -60,6 +62,7 @@ from gewu.transports.http_models import (
SearchRequestBody,
SourceListRequestBody,
)
from gewu.transports.ingest_reviews_http import register_ingest_review_routes
from gewu.transports.mcp_server import create_mcp_server
from gewu.transports.owner_mcp_server import OwnerReviewProvider, create_owner_mcp_server
from gewu.version import SERVICE_VERSION
@@ -79,6 +82,8 @@ def create_app(
project_registry: ProjectRegistry | None = None,
credential_administration: CredentialAdministration | None = None,
account_identity: AccountIdentity | None = None,
ingest_review_store: SQLiteIngestReviewStore | None = None,
jev_assessor: OpenRouterJev | None = None,
url_fetcher: PublicUrlFetcher | None = None,
) -> FastAPI:
if (identity_provider is None) != (node_reader is None):
@@ -94,6 +99,7 @@ def create_app(
or owner_identity_provider is not None
or credential_administration is not None
or account_identity is not None
or ingest_review_store is not None
):
app.add_middleware(ProjectRegistryBodyLimitMiddleware)
@@ -149,6 +155,11 @@ def create_app(
if account_identity is not None:
register_account_routes(app, account_identity, authenticate, require_same_origin)
if ingest_review_store is not None:
register_ingest_review_routes(
app, ingest_review_store, authenticate, require_same_origin
)
if owner_identity_provider is not None:
@app.post(
@@ -783,6 +794,8 @@ def create_app(
acceptance_service=acceptance_service,
context_provider=context_provider,
url_fetcher=url_fetcher or PublicUrlFetcher(frozenset()),
ingest_review_store=ingest_review_store,
jev_assessor=jev_assessor,
)
mcp_asgi = mcp_server.streamable_http_app(
streamable_http_path="/",
@@ -854,6 +867,7 @@ def create_app(
def create_local_app() -> FastAPI:
"""Compose the local persistent service from one private data root."""
_load_local_jev_environment()
data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute()
reject_symlink_components(data_root)
data_root.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -870,6 +884,7 @@ def create_local_app() -> FastAPI:
or None,
)
artifacts = LocalArtifactStore(data_root)
ingest_reviews = SQLiteIngestReviewStore(data_root / "ingest-reviews.sqlite3")
allowed_fetch_hosts = {
host.strip()
for host in os.environ.get("GEWU_FETCH_ALLOWED_HOSTS", "").split(",")
@@ -942,6 +957,15 @@ def create_local_app() -> FastAPI:
identity_provider=identities,
credential_administration=identities,
account_identity=identities,
ingest_review_store=ingest_reviews,
jev_assessor=(
OpenRouterJev(
os.environ["OPENROUTER_API_KEY"],
model=os.environ.get("OPENROUTER_JEV_MODEL", "typesafe/jev-1.13"),
)
if os.environ.get("OPENROUTER_API_KEY")
else None
),
owner_identity_provider=identities,
owner_service_audience=os.environ.get("GEWU_OWNER_SERVICE_AUDIENCE"),
node_reader=CanonicalNodeReader(repository),
@@ -953,3 +977,25 @@ def create_local_app() -> FastAPI:
project_registry=project_registry,
url_fetcher=url_fetcher,
)
def _load_local_jev_environment() -> None:
"""Load only Jev settings from the ignored, local project .env file."""
path = Path.cwd() / ".env"
if path.is_symlink() or not path.is_file():
return
path.chmod(0o600)
allowed = {"OPENROUTER_API_KEY", "OPENROUTER_JEV_MODEL"}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, _, value = line.partition("=")
name = name.strip()
value = value.strip()
if name not in allowed or not value:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
os.environ.setdefault(name, value)
+90
View File
@@ -19,6 +19,18 @@ from pydantic import (
)
SCHEMA_VERSION = 1
ResearchLens = Literal[
"technical",
"conceptual",
"cultural-historical",
"financial-market",
]
DEFAULT_RESEARCH_LENSES: tuple[ResearchLens, ...] = (
"technical",
"conceptual",
"cultural-historical",
"financial-market",
)
RecordId = Annotated[
str,
@@ -58,6 +70,7 @@ class ProjectRecord(StrictRecord):
owners: tuple[RecordId, ...] = ()
north_star: str = Field(min_length=1, max_length=4000)
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
default_includes: tuple[RecordId, ...] = ("shared",)
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
@@ -71,20 +84,97 @@ class ProjectRecord(StrictRecord):
return value
class IngestGuidance(StrictRecord):
guidance_id: RecordId
lens: ResearchLens
rule: str = Field(min_length=1, max_length=1000)
created_at: datetime
@field_validator("created_at")
@classmethod
def guidance_timestamp_is_utc(cls, value: datetime) -> datetime:
offset = value.utcoffset()
if offset is None or offset.total_seconds() != 0:
raise ValueError("guidance timestamp must be timezone-aware UTC")
return value
class ProjectLens(StrictRecord):
project_id: RecordId
title: str = Field(min_length=1, max_length=200)
status: ProjectStatus
north_star: str = Field(min_length=1, max_length=4000)
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
research_lenses: tuple[ResearchLens, ...] = DEFAULT_RESEARCH_LENSES
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
ingest_guidance: tuple[IngestGuidance, ...] = Field(default=(), max_length=100)
class ProjectContextResponse(StrictRecord):
lens: ProjectLens
class IngestReviewItem(StrictRecord):
review_id: RecordId
project_id: RecordId
run_id: RecordId
source_revision_id: RecordId
extraction_id: Sha256
locator: Locator
excerpt: str = Field(min_length=1, max_length=4000)
lens: ResearchLens
summary: str = Field(min_length=1, max_length=2000)
relevance_rationale: str = Field(min_length=1, max_length=2000)
relevance: Literal["borderline", "low"] = "borderline"
jev_relation_probability: FiniteFloat | None = Field(default=None, ge=0, le=1)
jev_lens: ResearchLens | None = None
jev_lens_confidence: FiniteFloat | None = Field(default=None, ge=0, le=1)
jev_model: str | None = Field(default=None, max_length=100)
status: Literal["needs-user-review", "included", "excluded", "deferred"] = "needs-user-review"
submitted_by: RecordId
created_at: datetime
@field_validator("created_at")
@classmethod
def review_timestamp_is_utc(cls, value: datetime) -> datetime:
offset = value.utcoffset()
if offset is None or offset.total_seconds() != 0:
raise ValueError("review timestamp must be timezone-aware UTC")
return value
class IngestReviewCreate(StrictRecord):
review_id: RecordId
project_id: RecordId
run_id: RecordId
source_revision_id: RecordId
extraction_id: Sha256
locator: Locator
excerpt: str = Field(min_length=1, max_length=4000)
lens: ResearchLens
summary: str = Field(min_length=1, max_length=2000)
relevance_rationale: str = Field(min_length=1, max_length=2000)
relevance: Literal["borderline", "low"] = "borderline"
class IngestReviewDecision(StrictRecord):
decision_id: RecordId
review_id: RecordId
decision: Literal["include", "exclude", "defer"]
rationale: str = Field(min_length=1, max_length=2000)
save_as_guidance: bool = False
guidance_rule: str | None = Field(default=None, max_length=1000)
@model_validator(mode="after")
def guidance_requires_explicit_rule(self) -> IngestReviewDecision:
if self.save_as_guidance and not (self.guidance_rule or "").strip():
raise ValueError("saving project guidance requires a non-empty guidance rule")
if not self.save_as_guidance and self.guidance_rule is not None:
raise ValueError("guidance_rule requires explicit save_as_guidance")
return self
class ReliabilityGrade(StrEnum):
LOW = "low"
MEDIUM = "medium"
+3
View File
@@ -50,6 +50,8 @@ OPERATIONS: Final[tuple[Operation, ...]] = (
Operation("project_archive", "project:admin", frozenset({Transport.HTTP})),
Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_project_context", "knowledge:read", frozenset({Transport.MCP})),
Operation("ingest_review_list", "ingest:review", frozenset({Transport.HTTP})),
Operation("ingest_review_decide", "ingest:review", frozenset({Transport.HTTP})),
Operation("search", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("list_sources", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
Operation("get_node", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
@@ -73,6 +75,7 @@ OPERATIONS: Final[tuple[Operation, ...]] = (
Operation("run_create", "run:write", frozenset({Transport.MCP})),
Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})),
Operation("run_status", "run:read", frozenset({Transport.MCP})),
Operation("ingest_review_create", "ingest:review", frozenset({Transport.MCP})),
Operation("owner_challenge_get", "owner:review", frozenset({Transport.OWNER_MCP})),
Operation("owner_diff_get", "owner:review", frozenset({Transport.OWNER_MCP})),
Operation("owner_decide", "owner:decide", frozenset({Transport.OWNER_MCP})),
+8
View File
@@ -25,6 +25,14 @@ class ProjectRegistryBodyLimitMiddleware:
"/v1/me/agent-credentials": MAX_OWNER_AUTH_BODY_BYTES,
}
path = scope.get("path")
if (
scope["type"] == "http"
and scope.get("method") == "POST"
and isinstance(path, str)
and path.startswith("/v1/ingest-reviews/")
and path.endswith("/decision")
):
limits[path] = 12 * 1024
if scope["type"] != "http" or scope.get("method") != "POST" or path not in limits:
await self.app(scope, receive, send)
return
@@ -0,0 +1,77 @@
"""Human review routes for borderline ingest candidates."""
from collections.abc import Callable
from typing import Annotated
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response
from gewu.adapters.sqlite_ingest_reviews import ReviewConflict, SQLiteIngestReviewStore
from gewu.application.identity import Principal
from gewu.domain.contracts import IngestReviewDecision, IngestReviewItem, RecordId
def register_ingest_review_routes(
app: FastAPI,
store: SQLiteIngestReviewStore,
authenticate: Callable[..., Principal],
require_same_origin: Callable[[Request], None],
) -> None:
def human(
request: Request,
response: Response,
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="gewu_session"),
csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"),
) -> Principal:
response.headers["Cache-Control"] = "private, no-store"
write = request.method not in {"GET", "HEAD"}
if write:
require_same_origin(request)
principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write)
if "agent" in principal.roles:
raise HTTPException(403, "A human account is required")
return principal
@app.get(
"/v1/ingest-reviews",
tags=["ingest review"],
operation_id="ingest_review_list",
)
def list_reviews(
principal: Annotated[Principal, Depends(human)],
project_id: RecordId,
status: str | None = Query(default="needs-user-review", max_length=32),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> list[IngestReviewItem]:
if status not in {None, "needs-user-review", "included", "excluded", "deferred"}:
raise HTTPException(422, "Review status is invalid")
if not principal.can_read_project(project_id):
raise HTTPException(404, "Project review queue is unavailable")
return store.list_items(project_id, status=status, limit=limit, offset=offset)
@app.post(
"/v1/ingest-reviews/{review_id}/decision",
tags=["ingest review"],
operation_id="ingest_review_decide",
)
def decide_review(
review_id: RecordId,
body: IngestReviewDecision,
principal: Annotated[Principal, Depends(human)],
) -> dict[str, object]:
if body.review_id != review_id:
raise HTTPException(422, "Decision review_id must match the route")
item = store.get(review_id)
if item is None or not principal.can_read_project(item.project_id):
raise HTTPException(404, "Review item is unavailable")
if not ({"editor", "admin"} & principal.roles):
raise HTTPException(403, "Editor role is required to decide review items")
try:
updated = store.decide(review_id, body, reviewer_id=principal.principal_id)
guidance = store.guidance(item.project_id) if body.save_as_guidance else ()
except ReviewConflict as error:
raise HTTPException(409, str(error)) from error
except KeyError as error:
raise HTTPException(404, "Review item is unavailable") from error
return {"item": updated, "guidance": guidance}
+103
View File
@@ -17,8 +17,10 @@ from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from pydantic import ValidationError
from gewu.adapters.openrouter_jev import JevUnavailable, OpenRouterJev
from gewu.adapters.pdf_extractor import PdfExtractionError
from gewu.adapters.source_extractor import SourceExtractor
from gewu.adapters.sqlite_ingest_reviews import SQLiteIngestReviewStore
from gewu.adapters.url_fetch import PublicUrlFetcher, UrlCaptureError
from gewu.application.acceptance import AcceptanceError, AcceptanceService, ActorContext
from gewu.application.context import ValidationContext
@@ -37,6 +39,7 @@ from gewu.domain.contracts import (
EvidenceReference,
ExtractionRecord,
GitCommitId,
IngestReviewCreate,
NeighborRequest,
NodeType,
ProjectContextResponse,
@@ -82,6 +85,8 @@ def create_mcp_server(
acceptance_service: AcceptanceService | None = None,
context_provider: Callable[[], ValidationContext] | None = None,
url_fetcher: PublicUrlFetcher | None = None,
ingest_review_store: SQLiteIngestReviewStore | None = None,
jev_assessor: OpenRouterJev | None = None,
) -> MCPServer:
"""Create the allowlisted MCP server for the supplied application services."""
@@ -353,11 +358,103 @@ def create_mcp_server(
"run_status",
}
)
if ingest_review_store is not None:
available_operations.add("ingest_review_create")
run_service = LocalRunService(
workflow_root=acceptance_service.root,
actor_is_current=acceptance_service.actor_is_current,
)
if ingest_review_store is not None:
@server.tool(name="ingest_review_create", structured_output=False)
def ingest_review_create(review_json: str, ctx: Context) -> str:
"""Persist a source-bound follow-up flag for human relevance feedback."""
principal = _principal(ctx, identity_provider)
if "agent" not in principal.roles or principal.auth_method != "bearer":
raise PermissionError(
"Ingest review submission requires an agent bearer credential"
)
if len(review_json.encode("utf-8")) > 16_000:
raise ValueError("Ingest review request exceeds the supported size")
try:
item = IngestReviewCreate.model_validate_json(review_json, strict=True)
except ValidationError as error:
raise ValueError(
"Ingest review request does not match the GEWU v1 contract"
) from error
_require_project_submission(principal, item.project_id)
actor = ActorContext(principal.principal_id, principal.credential_id)
try:
run = run_service.status(item.run_id, actor=actor)
except RunError as error:
raise ValueError(f"{error.code}: {error}") from error
_require_run_scope(principal, run.scope)
if item.project_id not in run.scope.projects:
raise ValueError("Ingest review project must be in the durable run scope")
extraction = artifact_store.get_extraction(
item.extraction_id,
owner_id=principal.principal_id,
project_id=item.project_id,
)
if extraction is None or extraction.source_revision_id != item.source_revision_id:
raise ValueError("Ingest review source extraction is unavailable")
block = next(
(
candidate
for candidate in extraction.blocks
if candidate.block_id == item.locator.block_id
),
None,
)
if (
block is None
or item.locator.end > len(block.normalized_text)
or block.normalized_text[item.locator.start : item.locator.end] != item.excerpt
):
raise ValueError(
"Ingest review excerpt must exactly match its extraction locator"
)
expected_kind = (
"pdf_page_span"
if block.page is not None
else "transcript_timestamp_span"
if block.time_start_ms is not None
else "text_span"
)
if (
item.locator.kind != expected_kind
or item.locator.page != block.page
or item.locator.time_start_ms != block.time_start_ms
or item.locator.time_end_ms != block.time_end_ms
):
raise ValueError(
"Ingest review locator metadata does not match its extraction block"
)
assessment = None
if jev_assessor is not None:
project = context_provider().projects.get(item.project_id)
if project is not None:
try:
assessment = jev_assessor.assess(item, project)
except JevUnavailable:
# The review flag and normal proposal flow do not depend on Jev uptime.
assessment = None
record = ingest_review_store.create(
item,
submitted_by=principal.principal_id,
jev_relation_probability=(
assessment.relation_probability if assessment is not None else None
),
jev_lens=assessment.lens if assessment is not None else None,
jev_lens_confidence=(
assessment.lens_confidence if assessment is not None else None
),
jev_model=assessment.model if assessment is not None else None,
)
return json.dumps(record.model_dump(mode="json"), ensure_ascii=False)
@server.tool(name="source_capture_text", structured_output=False)
def source_capture_text(
capture_id: str,
@@ -1016,8 +1113,14 @@ def create_mcp_server(
status=project.status,
north_star=project.north_star,
key_questions=project.key_questions,
research_lenses=project.research_lenses,
watch_list=project.watch_list,
taxonomy_extensions=project.taxonomy_extensions,
ingest_guidance=(
ingest_review_store.guidance(project_id)
if ingest_review_store is not None
else ()
),
),
)
encoded = json.dumps(response.model_dump(mode="json"), ensure_ascii=False)
+210
View File
@@ -196,6 +196,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/ingest-reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Reviews */
get: operations["ingest_review_list"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/ingest-reviews/{review_id}/decision": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Decide Review */
post: operations["ingest_review_decide"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/me": {
parameters: {
query?: never;
@@ -894,6 +928,91 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** IngestReviewDecision */
IngestReviewDecision: {
/**
* Decision
* @enum {string}
*/
decision: "include" | "exclude" | "defer";
/** Decision Id */
decision_id: string;
/** Guidance Rule */
guidance_rule?: string | null;
/** Rationale */
rationale: string;
/** Review Id */
review_id: string;
/**
* Save As Guidance
* @default false
*/
save_as_guidance: boolean;
/**
* Schema Version
* @default 1
* @constant
*/
schema_version: 1;
};
/** IngestReviewItem */
IngestReviewItem: {
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Excerpt */
excerpt: string;
/** Extraction Id */
extraction_id: string;
/** Jev Lens */
jev_lens?: ("technical" | "conceptual" | "cultural-historical" | "financial-market") | null;
/** Jev Lens Confidence */
jev_lens_confidence?: number | null;
/** Jev Model */
jev_model?: string | null;
/** Jev Relation Probability */
jev_relation_probability?: number | null;
/**
* Lens
* @enum {string}
*/
lens: "technical" | "conceptual" | "cultural-historical" | "financial-market";
locator: components["schemas"]["Locator"];
/** Project Id */
project_id: string;
/**
* Relevance
* @default borderline
* @enum {string}
*/
relevance: "borderline" | "low";
/** Relevance Rationale */
relevance_rationale: string;
/** Review Id */
review_id: string;
/** Run Id */
run_id: string;
/**
* Schema Version
* @default 1
* @constant
*/
schema_version: 1;
/** Source Revision Id */
source_revision_id: string;
/**
* Status
* @default needs-user-review
* @enum {string}
*/
status: "needs-user-review" | "included" | "excluded" | "deferred";
/** Submitted By */
submitted_by: string;
/** Summary */
summary: string;
};
/** IssuedCredential */
IssuedCredential: {
/** Credential Id */
@@ -1363,6 +1482,16 @@ export interface components {
owners: string[];
/** Project Id */
project_id: string;
/**
* Research Lenses
* @default [
* "technical",
* "conceptual",
* "cultural-historical",
* "financial-market"
* ]
*/
research_lenses: ("technical" | "conceptual" | "cultural-historical" | "financial-market")[];
/**
* Schema Version
* @default 1
@@ -3913,6 +4042,87 @@ export interface operations {
};
};
};
ingest_review_list: {
parameters: {
query: {
project_id: string;
status?: string | null;
limit?: number;
offset?: number;
};
header?: {
authorization?: string | null;
"X-GEWU-CSRF"?: string | null;
};
path?: never;
cookie?: {
gewu_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IngestReviewItem"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
ingest_review_decide: {
parameters: {
query?: never;
header?: {
authorization?: string | null;
"X-GEWU-CSRF"?: string | null;
};
path: {
review_id: string;
};
cookie?: {
gewu_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["IngestReviewDecision"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
account_current: {
parameters: {
query?: never;
+68 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CredentialsSection from './CredentialsSection';
import AccountLogin from './AccountLogin';
import ProjectsSection from './ProjectsSection';
@@ -8,6 +8,7 @@ import type { components } from '../generated/api';
const adminTabs = [
{ id: 'projects', label: 'Projects' },
{ id: 'ingest-review', label: 'Ingest review' },
{ id: 'users', label: 'Users' },
{ id: 'credentials', label: 'Credentials' },
{ id: 'logs', label: 'Logs' },
@@ -18,6 +19,69 @@ type AdminTab = typeof adminTabs[number]['id'];
type Credential = components['schemas']['CredentialSummary'];
type Activity = components['schemas']['AdminEvent'];
type Project = components['schemas']['ProjectRecord'];
type IngestReview = {
review_id: string; project_id: string; run_id: string; source_revision_id: string;
extraction_id: string; locator: { kind: string; block_id: string; start: number; end: number; page?: number | null };
excerpt: string; lens: string; summary: string; relevance_rationale: string;
jev_relation_probability?: number | null; jev_lens?: string | null;
jev_lens_confidence?: number | null; jev_model?: string | null;
relevance: 'borderline' | 'low'; status: string; created_at: string;
};
function IngestReviewSection({ csrf, projects, onError, onMessage }: {
csrf: string; projects: Project[]; onError: (message: string) => void; onMessage: (message: string) => void;
}) {
const [projectId, setProjectId] = useState('');
const [items, setItems] = useState<IngestReview[]>([]);
const [guidance, setGuidance] = useState<Record<string, string>>({});
const [busyId, setBusyId] = useState('');
const [rationales, setRationales] = useState<Record<string, string>>({});
const [saveGuidance, setSaveGuidance] = useState<Record<string, boolean>>({});
const refresh = useCallback(async () => {
if (!projectId) { setItems([]); return; }
try {
const rows = await request<IngestReview[]>(`/v1/ingest-reviews?project_id=${encodeURIComponent(projectId)}&status=needs-user-review&limit=100`, csrf);
setItems(rows);
} catch (failure) { onError(failure instanceof Error ? failure.message : 'Could not load review queue.'); }
}, [projectId, csrf, onError]);
useEffect(() => { void refresh(); }, [refresh]);
async function decide(item: IngestReview, decision: 'include' | 'exclude' | 'defer') {
const rationale = rationales[item.review_id]?.trim();
if (!rationale) { onError('Add a short reason for this decision.'); return; }
const wantsGuidance = saveGuidance[item.review_id] ?? false;
const guidanceRule = guidance[item.review_id]?.trim();
if (wantsGuidance && !guidanceRule) { onError('Write the reusable guidance rule to save it.'); return; }
setBusyId(item.review_id); onError('');
try {
await request(`/v1/ingest-reviews/${encodeURIComponent(item.review_id)}/decision`, csrf, {
method: 'POST',
body: JSON.stringify({ decision_id: crypto.randomUUID(), review_id: item.review_id, decision, rationale,
save_as_guidance: wantsGuidance, ...(wantsGuidance ? { guidance_rule: guidanceRule } : {}) }),
});
onMessage(decision === 'include' ? 'Relevance confirmed. Feedback saved; knowledge changes still use the normal proposal flow.' : decision === 'exclude' ? 'Marked not relevant. Feedback saved.' : 'Feedback deferred.');
await refresh();
} catch (failure) { onError(failure instanceof Error ? failure.message : 'Could not save review decision.'); }
finally { setBusyId(''); }
}
return <section className="admin-card" aria-labelledby="ingest-review-title">
<div className="admin-heading"><div><h2 id="ingest-review-title">Ingest review</h2><p className="muted-copy">These flags are recorded alongside normal ingestion and do not hold up proposals or publication. Your decision helps tune future project triage.</p><p className="muted-copy">If configured, Jev sends the project context and excerpt to OpenRouter for a relevance probability and lens suggestion. That signal is advisory.</p></div>
<button className="quiet-button" disabled={busyId !== '' || !projectId} onClick={() => void refresh()}>Refresh</button></div>
<label>Project<select value={projectId} onChange={event => setProjectId(event.target.value)}><option value="">Choose a project</option>{projects.filter(project => project.status !== 'archived').map(project => <option key={project.project_id} value={project.project_id}>{project.title}</option>)}</select></label>
{!projectId ? <p>Select a project to view its review flags.</p> : items.length === 0 ? <p>No review flags need feedback.</p> : <ol className="admin-activity">{items.map(item => <li key={item.review_id}>
<strong>{item.lens.replaceAll('-', ' ')} · {item.relevance} relevance · {item.summary}</strong>
<p>{item.excerpt}</p><p className="muted-copy">{item.relevance_rationale}</p>
{item.jev_relation_probability != null ? <p className="muted-copy">Jev relevance: {Math.round(item.jev_relation_probability * 100)}% · lens: {item.jev_lens ?? 'unknown'} ({item.jev_lens_confidence != null ? `${Math.round(item.jev_lens_confidence * 100)}% confidence` : 'no confidence score'}) · {item.jev_model}</p> : <p className="muted-copy">Jev signal unavailable or not configured.</p>}
<p className="muted-copy">Source {item.source_revision_id} · {item.locator.kind.replaceAll('_', ' ')} {item.locator.page ? `· page ${item.locator.page}` : ''} · run {item.run_id}</p>
<label>Decision reason<textarea maxLength={2000} value={rationales[item.review_id] ?? ''} onChange={event => setRationales(value => ({ ...value, [item.review_id]: event.target.value }))} /></label>
<label><input type="checkbox" checked={saveGuidance[item.review_id] ?? false} onChange={event => setSaveGuidance(value => ({ ...value, [item.review_id]: event.target.checked }))} /> Save a reusable rule for this project and lens</label>
{saveGuidance[item.review_id] && <label>Project guidance<textarea maxLength={1000} value={guidance[item.review_id] ?? ''} onChange={event => setGuidance(value => ({ ...value, [item.review_id]: event.target.value }))} placeholder="For example: include historical show production and audience context when it informs dome practice." /></label>}
<div className="admin-actions"><button disabled={busyId !== ''} onClick={() => void decide(item, 'include')}>Relevant</button><button className="quiet-button" disabled={busyId !== ''} onClick={() => void decide(item, 'defer')}>Defer feedback</button><button className="quiet-button" disabled={busyId !== ''} onClick={() => void decide(item, 'exclude')}>Not relevant</button></div>
</li>)}</ol>}
</section>;
}
function Admin({ personal = false }: { personal?: boolean }) {
const [activeTab, setActiveTab] = useState<AdminTab>('projects');
@@ -132,6 +196,9 @@ function Admin({ personal = false }: { personal?: boolean }) {
{!personal && <div id="admin-panel-projects" role="tabpanel" aria-labelledby="admin-tab-projects" hidden={activeTab !== 'projects'} tabIndex={0}>
<ProjectsSection csrf={csrf} projects={projectRecords} currentOwner={currentPrincipal} onCreated={refreshAdminData} onError={setError} onMessage={setMessage} />
</div>}
{!personal && <div id="admin-panel-ingest-review" role="tabpanel" aria-labelledby="admin-tab-ingest-review" hidden={activeTab !== 'ingest-review'} tabIndex={0}>
<IngestReviewSection csrf={csrf} projects={projectRecords} onError={setError} onMessage={setMessage} />
</div>}
{!personal && <div id="admin-panel-users" role="tabpanel" aria-labelledby="admin-tab-users" hidden={activeTab !== 'users'} tabIndex={0}>
<AccountsSection csrf={csrf} projects={userProjectOptions} onMessage={setMessage} onError={setError} />
</div>}