This commit is contained in:
1 parent
1c61c7ad04
commit
7b2c43ddaf
18 files changed
+1648
-2
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_JEV_MODEL=typesafe/jev-1.13
|
||||||
@@ -118,3 +118,7 @@ When adding GEWU into a user project folder that already has active agents, skil
|
|||||||
|
|
||||||
### URL capture scope
|
### 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.
|
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.
|
||||||
@@ -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
|
||||||
+1
-1
Submodule agents/gewu updated: 3f959c42bb...31826982e6.
@@ -219,6 +219,22 @@
|
|||||||
"mcp"
|
"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",
|
"capability": "knowledge:read",
|
||||||
"mutates_knowledge": false,
|
"mutates_knowledge": false,
|
||||||
@@ -409,6 +425,14 @@
|
|||||||
"mcp"
|
"mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"capability": "ingest:review",
|
||||||
|
"mutates_knowledge": false,
|
||||||
|
"name": "ingest_review_create",
|
||||||
|
"transports": [
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"capability": "owner:review",
|
"capability": "owner:review",
|
||||||
"mutates_knowledge": false,
|
"mutates_knowledge": false,
|
||||||
|
|||||||
@@ -1131,6 +1131,249 @@
|
|||||||
"title": "HTTPValidationError",
|
"title": "HTTPValidationError",
|
||||||
"type": "object"
|
"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": {
|
"IssuedCredential": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"credential_id": {
|
"credential_id": {
|
||||||
@@ -2308,6 +2551,25 @@
|
|||||||
"title": "Project Id",
|
"title": "Project Id",
|
||||||
"type": "string"
|
"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": {
|
"schema_version": {
|
||||||
"const": 1,
|
"const": 1,
|
||||||
"default": 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": {
|
"/v1/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "account_current",
|
"operationId": "account_current",
|
||||||
|
|||||||
@@ -71,6 +71,25 @@
|
|||||||
"title": "Project Id",
|
"title": "Project Id",
|
||||||
"type": "string"
|
"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": {
|
"schema_version": {
|
||||||
"const": 1,
|
"const": 1,
|
||||||
"default": 1,
|
"default": 1,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
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_search import SQLiteSearchIndex
|
||||||
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
||||||
from gewu.application.acceptance import AcceptanceService
|
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")
|
repository = GitCanonicalRepository(data_root / "knowledge.git")
|
||||||
identities = SQLiteIdentityStore(data_root / "identity.sqlite3")
|
identities = SQLiteIdentityStore(data_root / "identity.sqlite3")
|
||||||
artifact_store = LocalArtifactStore(data_root / "artifacts")
|
artifact_store = LocalArtifactStore(data_root / "artifacts")
|
||||||
|
ingest_review_store = SQLiteIngestReviewStore(data_root / "ingest-reviews.sqlite3")
|
||||||
|
|
||||||
def context_provider() -> ValidationContext:
|
def context_provider() -> ValidationContext:
|
||||||
return ValidationContext({}, {}, {}, {}, {})
|
return ValidationContext({}, {}, {}, {}, {})
|
||||||
@@ -62,6 +64,7 @@ async def _tool_inventory(data_root: Path) -> list[dict[str, Any]]:
|
|||||||
extractor=TextMarkdownExtractor(),
|
extractor=TextMarkdownExtractor(),
|
||||||
acceptance_service=acceptance_service,
|
acceptance_service=acceptance_service,
|
||||||
context_provider=context_provider,
|
context_provider=context_provider,
|
||||||
|
ingest_review_store=ingest_review_store,
|
||||||
)
|
)
|
||||||
tools = await server.list_tools()
|
tools = await server.list_tools()
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -14,8 +14,10 @@ from starlette.routing import Mount
|
|||||||
|
|
||||||
from gewu.adapters.embeddings import LocalSentenceTransformer
|
from gewu.adapters.embeddings import LocalSentenceTransformer
|
||||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
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.source_extractor import LocalSourceExtractor, SourceExtractor
|
||||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
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_search import SQLiteSearchIndex
|
||||||
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
|
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
|
||||||
from gewu.adapters.url_fetch import PublicUrlFetcher
|
from gewu.adapters.url_fetch import PublicUrlFetcher
|
||||||
@@ -60,6 +62,7 @@ from gewu.transports.http_models import (
|
|||||||
SearchRequestBody,
|
SearchRequestBody,
|
||||||
SourceListRequestBody,
|
SourceListRequestBody,
|
||||||
)
|
)
|
||||||
|
from gewu.transports.ingest_reviews_http import register_ingest_review_routes
|
||||||
from gewu.transports.mcp_server import create_mcp_server
|
from gewu.transports.mcp_server import create_mcp_server
|
||||||
from gewu.transports.owner_mcp_server import OwnerReviewProvider, create_owner_mcp_server
|
from gewu.transports.owner_mcp_server import OwnerReviewProvider, create_owner_mcp_server
|
||||||
from gewu.version import SERVICE_VERSION
|
from gewu.version import SERVICE_VERSION
|
||||||
@@ -79,6 +82,8 @@ def create_app(
|
|||||||
project_registry: ProjectRegistry | None = None,
|
project_registry: ProjectRegistry | None = None,
|
||||||
credential_administration: CredentialAdministration | None = None,
|
credential_administration: CredentialAdministration | None = None,
|
||||||
account_identity: AccountIdentity | None = None,
|
account_identity: AccountIdentity | None = None,
|
||||||
|
ingest_review_store: SQLiteIngestReviewStore | None = None,
|
||||||
|
jev_assessor: OpenRouterJev | None = None,
|
||||||
url_fetcher: PublicUrlFetcher | None = None,
|
url_fetcher: PublicUrlFetcher | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
if (identity_provider is None) != (node_reader is None):
|
if (identity_provider is None) != (node_reader is None):
|
||||||
@@ -94,6 +99,7 @@ def create_app(
|
|||||||
or owner_identity_provider is not None
|
or owner_identity_provider is not None
|
||||||
or credential_administration is not None
|
or credential_administration is not None
|
||||||
or account_identity is not None
|
or account_identity is not None
|
||||||
|
or ingest_review_store is not None
|
||||||
):
|
):
|
||||||
app.add_middleware(ProjectRegistryBodyLimitMiddleware)
|
app.add_middleware(ProjectRegistryBodyLimitMiddleware)
|
||||||
|
|
||||||
@@ -149,6 +155,11 @@ def create_app(
|
|||||||
if account_identity is not None:
|
if account_identity is not None:
|
||||||
register_account_routes(app, account_identity, authenticate, require_same_origin)
|
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:
|
if owner_identity_provider is not None:
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
@@ -783,6 +794,8 @@ def create_app(
|
|||||||
acceptance_service=acceptance_service,
|
acceptance_service=acceptance_service,
|
||||||
context_provider=context_provider,
|
context_provider=context_provider,
|
||||||
url_fetcher=url_fetcher or PublicUrlFetcher(frozenset()),
|
url_fetcher=url_fetcher or PublicUrlFetcher(frozenset()),
|
||||||
|
ingest_review_store=ingest_review_store,
|
||||||
|
jev_assessor=jev_assessor,
|
||||||
)
|
)
|
||||||
mcp_asgi = mcp_server.streamable_http_app(
|
mcp_asgi = mcp_server.streamable_http_app(
|
||||||
streamable_http_path="/",
|
streamable_http_path="/",
|
||||||
@@ -854,6 +867,7 @@ def create_app(
|
|||||||
def create_local_app() -> FastAPI:
|
def create_local_app() -> FastAPI:
|
||||||
"""Compose the local persistent service from one private data root."""
|
"""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()
|
data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute()
|
||||||
reject_symlink_components(data_root)
|
reject_symlink_components(data_root)
|
||||||
data_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
data_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
@@ -870,6 +884,7 @@ def create_local_app() -> FastAPI:
|
|||||||
or None,
|
or None,
|
||||||
)
|
)
|
||||||
artifacts = LocalArtifactStore(data_root)
|
artifacts = LocalArtifactStore(data_root)
|
||||||
|
ingest_reviews = SQLiteIngestReviewStore(data_root / "ingest-reviews.sqlite3")
|
||||||
allowed_fetch_hosts = {
|
allowed_fetch_hosts = {
|
||||||
host.strip()
|
host.strip()
|
||||||
for host in os.environ.get("GEWU_FETCH_ALLOWED_HOSTS", "").split(",")
|
for host in os.environ.get("GEWU_FETCH_ALLOWED_HOSTS", "").split(",")
|
||||||
@@ -942,6 +957,15 @@ def create_local_app() -> FastAPI:
|
|||||||
identity_provider=identities,
|
identity_provider=identities,
|
||||||
credential_administration=identities,
|
credential_administration=identities,
|
||||||
account_identity=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_identity_provider=identities,
|
||||||
owner_service_audience=os.environ.get("GEWU_OWNER_SERVICE_AUDIENCE"),
|
owner_service_audience=os.environ.get("GEWU_OWNER_SERVICE_AUDIENCE"),
|
||||||
node_reader=CanonicalNodeReader(repository),
|
node_reader=CanonicalNodeReader(repository),
|
||||||
@@ -953,3 +977,25 @@ def create_local_app() -> FastAPI:
|
|||||||
project_registry=project_registry,
|
project_registry=project_registry,
|
||||||
url_fetcher=url_fetcher,
|
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)
|
||||||
@@ -19,6 +19,18 @@ from pydantic import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 1
|
||||||
|
ResearchLens = Literal[
|
||||||
|
"technical",
|
||||||
|
"conceptual",
|
||||||
|
"cultural-historical",
|
||||||
|
"financial-market",
|
||||||
|
]
|
||||||
|
DEFAULT_RESEARCH_LENSES: tuple[ResearchLens, ...] = (
|
||||||
|
"technical",
|
||||||
|
"conceptual",
|
||||||
|
"cultural-historical",
|
||||||
|
"financial-market",
|
||||||
|
)
|
||||||
|
|
||||||
RecordId = Annotated[
|
RecordId = Annotated[
|
||||||
str,
|
str,
|
||||||
@@ -58,6 +70,7 @@ class ProjectRecord(StrictRecord):
|
|||||||
owners: tuple[RecordId, ...] = ()
|
owners: tuple[RecordId, ...] = ()
|
||||||
north_star: str = Field(min_length=1, max_length=4000)
|
north_star: str = Field(min_length=1, max_length=4000)
|
||||||
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
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)
|
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||||
default_includes: tuple[RecordId, ...] = ("shared",)
|
default_includes: tuple[RecordId, ...] = ("shared",)
|
||||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100)
|
||||||
@@ -71,20 +84,97 @@ class ProjectRecord(StrictRecord):
|
|||||||
return value
|
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):
|
class ProjectLens(StrictRecord):
|
||||||
project_id: RecordId
|
project_id: RecordId
|
||||||
title: str = Field(min_length=1, max_length=200)
|
title: str = Field(min_length=1, max_length=200)
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
north_star: str = Field(min_length=1, max_length=4000)
|
north_star: str = Field(min_length=1, max_length=4000)
|
||||||
key_questions: tuple[str, ...] = Field(min_length=1, max_length=30)
|
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)
|
watch_list: tuple[str, ...] = Field(default=(), max_length=100)
|
||||||
taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, 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):
|
class ProjectContextResponse(StrictRecord):
|
||||||
lens: ProjectLens
|
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):
|
class ReliabilityGrade(StrEnum):
|
||||||
LOW = "low"
|
LOW = "low"
|
||||||
MEDIUM = "medium"
|
MEDIUM = "medium"
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ OPERATIONS: Final[tuple[Operation, ...]] = (
|
|||||||
Operation("project_archive", "project:admin", frozenset({Transport.HTTP})),
|
Operation("project_archive", "project:admin", frozenset({Transport.HTTP})),
|
||||||
Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||||
Operation("get_project_context", "knowledge:read", frozenset({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("search", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})),
|
||||||
Operation("list_sources", "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})),
|
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_create", "run:write", frozenset({Transport.MCP})),
|
||||||
Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})),
|
Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})),
|
||||||
Operation("run_status", "run:read", 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_challenge_get", "owner:review", frozenset({Transport.OWNER_MCP})),
|
||||||
Operation("owner_diff_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})),
|
Operation("owner_decide", "owner:decide", frozenset({Transport.OWNER_MCP})),
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ class ProjectRegistryBodyLimitMiddleware:
|
|||||||
"/v1/me/agent-credentials": MAX_OWNER_AUTH_BODY_BYTES,
|
"/v1/me/agent-credentials": MAX_OWNER_AUTH_BODY_BYTES,
|
||||||
}
|
}
|
||||||
path = scope.get("path")
|
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:
|
if scope["type"] != "http" or scope.get("method") != "POST" or path not in limits:
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
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}
|
||||||
@@ -17,8 +17,10 @@ from mcp.server import MCPServer
|
|||||||
from mcp.server.mcpserver import Context
|
from mcp.server.mcpserver import Context
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from gewu.adapters.openrouter_jev import JevUnavailable, OpenRouterJev
|
||||||
from gewu.adapters.pdf_extractor import PdfExtractionError
|
from gewu.adapters.pdf_extractor import PdfExtractionError
|
||||||
from gewu.adapters.source_extractor import SourceExtractor
|
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.adapters.url_fetch import PublicUrlFetcher, UrlCaptureError
|
||||||
from gewu.application.acceptance import AcceptanceError, AcceptanceService, ActorContext
|
from gewu.application.acceptance import AcceptanceError, AcceptanceService, ActorContext
|
||||||
from gewu.application.context import ValidationContext
|
from gewu.application.context import ValidationContext
|
||||||
@@ -37,6 +39,7 @@ from gewu.domain.contracts import (
|
|||||||
EvidenceReference,
|
EvidenceReference,
|
||||||
ExtractionRecord,
|
ExtractionRecord,
|
||||||
GitCommitId,
|
GitCommitId,
|
||||||
|
IngestReviewCreate,
|
||||||
NeighborRequest,
|
NeighborRequest,
|
||||||
NodeType,
|
NodeType,
|
||||||
ProjectContextResponse,
|
ProjectContextResponse,
|
||||||
@@ -82,6 +85,8 @@ def create_mcp_server(
|
|||||||
acceptance_service: AcceptanceService | None = None,
|
acceptance_service: AcceptanceService | None = None,
|
||||||
context_provider: Callable[[], ValidationContext] | None = None,
|
context_provider: Callable[[], ValidationContext] | None = None,
|
||||||
url_fetcher: PublicUrlFetcher | None = None,
|
url_fetcher: PublicUrlFetcher | None = None,
|
||||||
|
ingest_review_store: SQLiteIngestReviewStore | None = None,
|
||||||
|
jev_assessor: OpenRouterJev | None = None,
|
||||||
) -> MCPServer:
|
) -> MCPServer:
|
||||||
"""Create the allowlisted MCP server for the supplied application services."""
|
"""Create the allowlisted MCP server for the supplied application services."""
|
||||||
|
|
||||||
@@ -353,11 +358,103 @@ def create_mcp_server(
|
|||||||
"run_status",
|
"run_status",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if ingest_review_store is not None:
|
||||||
|
available_operations.add("ingest_review_create")
|
||||||
run_service = LocalRunService(
|
run_service = LocalRunService(
|
||||||
workflow_root=acceptance_service.root,
|
workflow_root=acceptance_service.root,
|
||||||
actor_is_current=acceptance_service.actor_is_current,
|
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)
|
@server.tool(name="source_capture_text", structured_output=False)
|
||||||
def source_capture_text(
|
def source_capture_text(
|
||||||
capture_id: str,
|
capture_id: str,
|
||||||
@@ -1016,8 +1113,14 @@ def create_mcp_server(
|
|||||||
status=project.status,
|
status=project.status,
|
||||||
north_star=project.north_star,
|
north_star=project.north_star,
|
||||||
key_questions=project.key_questions,
|
key_questions=project.key_questions,
|
||||||
|
research_lenses=project.research_lenses,
|
||||||
watch_list=project.watch_list,
|
watch_list=project.watch_list,
|
||||||
taxonomy_extensions=project.taxonomy_extensions,
|
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)
|
encoded = json.dumps(response.model_dump(mode="json"), ensure_ascii=False)
|
||||||
|
|||||||
@@ -196,6 +196,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/me": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -894,6 +928,91 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
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 */
|
||||||
IssuedCredential: {
|
IssuedCredential: {
|
||||||
/** Credential Id */
|
/** Credential Id */
|
||||||
@@ -1363,6 +1482,16 @@ export interface components {
|
|||||||
owners: string[];
|
owners: string[];
|
||||||
/** Project Id */
|
/** Project Id */
|
||||||
project_id: string;
|
project_id: string;
|
||||||
|
/**
|
||||||
|
* Research Lenses
|
||||||
|
* @default [
|
||||||
|
* "technical",
|
||||||
|
* "conceptual",
|
||||||
|
* "cultural-historical",
|
||||||
|
* "financial-market"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
research_lenses: ("technical" | "conceptual" | "cultural-historical" | "financial-market")[];
|
||||||
/**
|
/**
|
||||||
* Schema Version
|
* Schema Version
|
||||||
* @default 1
|
* @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: {
|
account_current: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+68
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import CredentialsSection from './CredentialsSection';
|
import CredentialsSection from './CredentialsSection';
|
||||||
import AccountLogin from './AccountLogin';
|
import AccountLogin from './AccountLogin';
|
||||||
import ProjectsSection from './ProjectsSection';
|
import ProjectsSection from './ProjectsSection';
|
||||||
@@ -8,6 +8,7 @@ import type { components } from '../generated/api';
|
|||||||
|
|
||||||
const adminTabs = [
|
const adminTabs = [
|
||||||
{ id: 'projects', label: 'Projects' },
|
{ id: 'projects', label: 'Projects' },
|
||||||
|
{ id: 'ingest-review', label: 'Ingest review' },
|
||||||
{ id: 'users', label: 'Users' },
|
{ id: 'users', label: 'Users' },
|
||||||
{ id: 'credentials', label: 'Credentials' },
|
{ id: 'credentials', label: 'Credentials' },
|
||||||
{ id: 'logs', label: 'Logs' },
|
{ id: 'logs', label: 'Logs' },
|
||||||
@@ -18,6 +19,69 @@ type AdminTab = typeof adminTabs[number]['id'];
|
|||||||
type Credential = components['schemas']['CredentialSummary'];
|
type Credential = components['schemas']['CredentialSummary'];
|
||||||
type Activity = components['schemas']['AdminEvent'];
|
type Activity = components['schemas']['AdminEvent'];
|
||||||
type Project = components['schemas']['ProjectRecord'];
|
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 }) {
|
function Admin({ personal = false }: { personal?: boolean }) {
|
||||||
const [activeTab, setActiveTab] = useState<AdminTab>('projects');
|
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}>
|
{!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} />
|
<ProjectsSection csrf={csrf} projects={projectRecords} currentOwner={currentPrincipal} onCreated={refreshAdminData} onError={setError} onMessage={setMessage} />
|
||||||
</div>}
|
</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}>
|
{!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} />
|
<AccountsSection csrf={csrf} projects={userProjectOptions} onMessage={setMessage} onError={setError} />
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
Reference in new issue
Block a user