Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ infra/cdk.out/

# Verification evidence is binary + point-in-time: attach to the task PR, never commit
docs/tasks/*/evidence/

# Notebook checkpoints
*checkpoint.ipynb

# Claude worktrees
.claude/worktrees
2 changes: 2 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ node_modules/
.vite/
*.tsbuildinfo
cdk.context.json
*checkpoint.ipynb
notebooks/

# Tests are not needed to install or run the production project.
tests/
73 changes: 73 additions & 0 deletions backend/alembic/versions/b7c1e5a9d340_user_feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""user_feedback table

In-app human feedback for task 032: a per-source "not relevant" flag and a
free-text issue report, in one append-only table keyed by a `kind`
discriminator. Nothing in the pipeline reads these rows.

Revision ID: b7c1e5a9d340
Revises: d8e4a1c7f2b9
Create Date: 2026-08-13 00:00:00.000000

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "b7c1e5a9d340"
down_revision: Union[str, None] = "d8e4a1c7f2b9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"user_feedback",
sa.Column("user_feedback_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"project_source_snapshot_id", postgresql.UUID(as_uuid=True), nullable=True
),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("page_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("user_feedback_id"),
sa.ForeignKeyConstraint(["project_id"], ["project.project_id"]),
sa.ForeignKeyConstraint(
["project_source_snapshot_id", "project_id"],
[
"project_source_snapshot.project_source_snapshot_id",
"project_source_snapshot.project_id",
],
name="fk_ufb_pss_project",
match="SIMPLE",
),
sa.CheckConstraint(
"kind IN ('source_not_relevant', 'issue_report')", name="ck_ufb_kind"
),
sa.CheckConstraint(
"(kind = 'source_not_relevant'"
" AND project_source_snapshot_id IS NOT NULL AND body IS NULL)"
" OR (kind = 'issue_report'"
" AND project_source_snapshot_id IS NULL AND body IS NOT NULL)",
name="ck_ufb_shape",
),
)
op.create_index(
"ux_ufb_source_flag",
"user_feedback",
["project_source_snapshot_id", "user_id"],
unique=True,
postgresql_where=sa.text("kind = 'source_not_relevant'"),
)
op.create_index("ix_ufb_project_kind", "user_feedback", ["project_id", "kind"])


def downgrade() -> None:
op.drop_index("ix_ufb_project_kind", table_name="user_feedback")
op.drop_index("ux_ufb_source_flag", table_name="user_feedback")
op.drop_table("user_feedback")
2 changes: 2 additions & 0 deletions backend/src/policy_atlas/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def readyz(request: Request) -> dict[str, str]:
project_router as project_conversations_router,
)
from policy_atlas.api.routers.conversations import router as conversations_router
from policy_atlas.api.routers.feedback import router as feedback_router
from policy_atlas.api.routers.planning import router as planning_router
from policy_atlas.api.routers.projects import router as projects_router
from policy_atlas.api.routers.read_models import router as read_models_router
Expand All @@ -161,6 +162,7 @@ def readyz(request: Request) -> dict[str, str]:
conversations_router,
sse_router,
read_models_router,
feedback_router,
*routers,
):
app.include_router(router)
Expand Down
12 changes: 12 additions & 0 deletions backend/src/policy_atlas/api/contract/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
Page,
PageMeta,
)
from .feedback import (
ISSUE_REPORT_BODY_MAX,
IssueReportCreate,
IssueReportOut,
SourceFeedbackOut,
SourceFeedbackUpdate,
)
from .planning import (
AnalysisDepth,
BackendScope,
Expand Down Expand Up @@ -143,6 +150,7 @@
__all__ = [
"EVIDENCE_STATUS_INCLUDED",
"CHAT_MESSAGE_MAX",
"ISSUE_REPORT_BODY_MAX",
"PAGE_SIZE_DEFAULT",
"PAGE_SIZE_MAX",
"PROJECT_NAME_MAX",
Expand Down Expand Up @@ -208,6 +216,8 @@
"IcfFindingOut",
"IofFindingOut",
"IofStatisticsOut",
"IssueReportCreate",
"IssueReportOut",
"FreeTextCompileOut",
"FreeTextConfirmResponse",
"FreeTextResponse",
Expand Down Expand Up @@ -247,6 +257,8 @@
"SectionOut",
"SectionRole",
"SourceDossierOut",
"SourceFeedbackOut",
"SourceFeedbackUpdate",
"SourceTagOut",
"SseFrame",
"StageCompletedFrame",
Expand Down
69 changes: 69 additions & 0 deletions backend/src/policy_atlas/api/contract/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""User-feedback contract: the source relevance flag and the issue report.

Both are human-authored and LLM-free (spec § Feedback). The source flag is
feedback only: it never moves a source on the evidence status ladder and never
changes what the pipeline selects, reads or cites.
"""

from __future__ import annotations

import uuid
from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field

#: Issue-report body length bound (spec § Feedback: `POST .../issue-reports`).
ISSUE_REPORT_BODY_MAX = 4000


class SourceFeedbackUpdate(BaseModel):
"""Inbound body for `PATCH /api/v1/projects/{id}/sources/{source_id}`.

Args:
not_relevant: Whether the caller marks this source as not relevant.
Idempotent in both directions.
"""

model_config = ConfigDict(extra="forbid")

not_relevant: bool


class SourceFeedbackOut(BaseModel):
"""The caller's feedback state for one source.

Args:
source_id: The source the flag applies to.
not_relevant: The flag's state after the write.
"""

source_id: uuid.UUID
not_relevant: bool


class IssueReportCreate(BaseModel):
"""Inbound body for `POST /api/v1/projects/{id}/issue-reports`.

Args:
body: What the user noticed, 1-4000 characters. Outer whitespace is
stripped before the length constraint is applied
(`str_strip_whitespace`), so a whitespace-only report is rejected.
page_path: The in-app path the report was raised from, when known.
"""

model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

body: str = Field(min_length=1, max_length=ISSUE_REPORT_BODY_MAX)
page_path: str | None = Field(default=None, max_length=2000)


class IssueReportOut(BaseModel):
"""A recorded issue report's receipt.

Args:
feedback_id: The stored feedback row's identity.
created_at: When the report was recorded.
"""

feedback_id: uuid.UUID
created_at: datetime
6 changes: 6 additions & 0 deletions backend/src/policy_atlas/api/contract/read_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ class EvidenceItemOut(BaseModel):
appraisal_tier: Optional appraisal tier label.
cited: Whether this source is cited in the artefact.
url: Optional source URL.
not_relevant: Whether a human has flagged this source as not relevant.
Feedback only — it never moves the source on the status ladder.
"""

source_id: uuid.UUID
Expand All @@ -211,6 +213,10 @@ class EvidenceItemOut(BaseModel):
screen_reason: str | None = None
classification_reason: str | None = None
read_in_full: bool = False
# 032 feedback (additive): the human relevance flag, read back so the user
# can see and undo it. Deliberately not folded into `status`/`screen_status`
# — the ladder stays machine-derived.
not_relevant: bool = False


class FindingBaseOut(BaseModel):
Expand Down
21 changes: 21 additions & 0 deletions backend/src/policy_atlas/api/readmodels/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
source_snapshot,
source_tag,
synthesis_result,
user_feedback,
)
from policy_atlas.evidence_base.assess.appraise import SCORE_LABELS
from policy_atlas.evidence_base.assess.screen import effective_screen_rows
Expand Down Expand Up @@ -554,6 +555,23 @@ def _source_reason_maps(
return screen_reasons, classification_reasons


def _not_relevant_sources(conn: Connection, project_id: uuid.UUID) -> set[uuid.UUID]:
"""Sources a human has flagged as not relevant in this project.

Scoped by project rather than by caller: only the project's owner can read
these read models at all, so a per-user filter would partition a set of
one. Revisit if projects ever gain collaborators.
"""
return set(
conn.execute(
select(user_feedback.c.project_source_snapshot_id).where(
user_feedback.c.project_id == project_id,
user_feedback.c.kind == "source_not_relevant",
)
).scalars()
)


def _expand_evidence_statuses(values: Iterable[str]) -> set[str]:
"""Expand the `Included` filter shortcut into its ladder positions."""
expanded: set[str] = set()
Expand Down Expand Up @@ -658,6 +676,7 @@ def evidence_page(
cited_snapshots = (
_cited_snapshot_ids(conn, synthesis["artefact_id"]) if synthesis is not None else set()
)
not_relevant = _not_relevant_sources(conn, project_id)
themed_sources = (
set(
conn.execute(
Expand Down Expand Up @@ -753,6 +772,7 @@ def evidence_page(
row.project_source_snapshot_id
),
read_in_full=row.full_text_status == "ingested",
not_relevant=row.project_source_snapshot_id in not_relevant,
),
appraisal.quality_score if appraisal is not None else None,
)
Expand Down Expand Up @@ -2006,6 +2026,7 @@ def source_dossier_out(
screen_reason=screen_reasons.get(source_id),
classification_reason=classification_reasons.get(source_id),
read_in_full=row["full_text_status"] == "ingested",
not_relevant=source_id in _not_relevant_sources(conn, project_id),
abstract=abstract,
abstract_source="llm_description"
if raw_abstract_source == "llm_description"
Expand Down
Loading