diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..da301fe --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# SourceOS + +SourceOS is a personal operating system for turning traceable reality signals into product decisions. Its language separates evidence from claims and bounded agent action from operator authority. + +## Agent Operation + +**Discovery Agent**: +The bounded actor that plans and performs approved public-source collection, then returns traceable evidence, counterevidence, unknowns, and a proposed next action. It never makes product, commercial, or external-contact decisions. +_Avoid_: autonomous founder, full agent, chat assistant + +**Operator Approval**: +The explicit decision by the operator that permits an action outside the Discovery Agent's approved source, tool, or budget boundary. +_Avoid_: confirmation, consent, automatic escalation + +**Approved Collection Boundary**: +The versioned set of public sources, tools, request, time, and cost limits within which a Discovery Agent may act without a new Operator Approval. +_Avoid_: agent permission, unrestricted access + +**Discovery Objective**: +The operator-owned, falsifiable question that gives a Discovery Agent a continuing reason to learn until its stop condition, pause, or budget limit is reached. +_Avoid_: project, research task, agent prompt + +**Acquisition Plan**: +A versioned proposal from a Discovery Agent that states how one Discovery Objective should next be investigated, including sources, hypotheses, counterevidence targets, and bounded runs. +_Avoid_: mission, schedule, crawl configuration + +**Plan Revision**: +A recorded change from one Acquisition Plan version to another that names the evidence or coverage gap responsible for the change. A revision may only use the current Approved Collection Boundary. +_Avoid_: retuning, silent optimization, agent learning + +**Discovery Assessment**: +A versioned, evidence-cited Agent judgement about an Objective's support, counterevidence, unknowns, coverage gaps, and recommended next plan. It is not a business decision. +_Avoid_: answer, conclusion, validated result + +**Need Hypothesis**: +An Agent-drafted, falsifiable explanation of a possible unmet need that awaits operator promotion to a Need Issue. +_Avoid_: discovered need, validated demand, product opportunity + +**Blocked Assessment**: +A Discovery Assessment that states the Agent cannot produce a defensible next plan within the remaining evidence, boundary, and budget, and names the missing information or approval. +_Avoid_: failure, no result, keep researching + +**Outcome Feedback**: +Observed tracking, delivery, retention, payment, refund, or support results from a product or service that the Discovery Agent may read to calibrate future discovery. It does not authorize the Agent to execute product or commercial actions. +_Avoid_: Agent action, market proof by itself + +**Discovery Objective Workspace**: +The operator interface for one Discovery Objective, presenting its stop conditions, current Assessment, plan revisions, bounded runs, evidence, and approval decisions. It is not a chat-first interface. +_Avoid_: dashboard, task board, agent chat + +**Discovery Decision Record**: +The closing record for a Discovery Objective, stating its decision state, cited support and counterevidence, resource use, unresolved unknowns, and later Outcome Feedback. It is the unit through which discovery quality is revisited. +_Avoid_: agent score, success metric, final answer + +**Acquisition Mission**: +A concrete, approved collection instruction within one Acquisition Plan; it directs a bounded run and is not evidence or a demand claim. +_Avoid_: discovery objective, task, crawl job + +**Evidence Candidate**: +Traceable collected material awaiting operator triage; its existence does not establish a Need Issue or product demand. +_Avoid_: validated insight, discovered need diff --git a/apps/api/main.py b/apps/api/main.py index bc51f2e..307016f 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -15,6 +15,7 @@ acquisition_missions, agent_runs, delivery_records, + discovery_objectives, experiments, export, external_signals, @@ -90,6 +91,11 @@ async def lifespan(app: FastAPI): app.include_router(export.router, prefix="/api/export", tags=["Export"]) app.include_router(need_issues.router, prefix="/api/need-issues", tags=["Need Issues"]) app.include_router(agent_runs.router, prefix="/api/agent-runs", tags=["Agent Runs"]) +app.include_router( + agent_runs.objective_router, + prefix="/api/discovery-objectives", + tags=["Discovery Agent Runs"], +) app.include_router(experiments.router, prefix="/api/experiments", tags=["Validation Experiments"]) app.include_router(product_theses.router, prefix="/api/product-theses", tags=["Product Theses"]) app.include_router(features.router, prefix="/api/features", tags=["Feature Definitions"]) @@ -100,6 +106,11 @@ async def lifespan(app: FastAPI): feature_outcomes.router, prefix="/api/feature-outcomes", tags=["Feature Outcomes"] ) app.include_router(today.router, prefix="/api/today", tags=["Today"]) +app.include_router( + discovery_objectives.router, + prefix="/api/discovery-objectives", + tags=["Discovery Objectives"], +) app.include_router( ontology_hypotheses.router, prefix="/api/ontology-hypotheses", tags=["Ontology Hypotheses"] ) diff --git a/apps/api/routers/acquisition_mission_runs.py b/apps/api/routers/acquisition_mission_runs.py index 962a62f..1a932fb 100644 --- a/apps/api/routers/acquisition_mission_runs.py +++ b/apps/api/routers/acquisition_mission_runs.py @@ -38,6 +38,11 @@ from packages.storage.models.acquisition_mission_run_signal import ( AcquisitionMissionRunSignal, ) +from packages.storage.models.acquisition_plan import AcquisitionPlan +from packages.storage.models.discovery_objective import ( + ApprovedCollectionBoundary, + DiscoveryObjective, +) from packages.storage.models.external_signal import ExternalSignal from packages.storage.models.source import Source @@ -57,6 +62,26 @@ async def _get_mission_or_404(db: AsyncSession, mission_id: uuid.UUID) -> Acquis raise HTTPException( status_code=422, detail="Acquisition Mission has no pinned source version" ) + if mission.acquisition_plan_id is not None: + plan = await db.get(AcquisitionPlan, mission.acquisition_plan_id) + if plan is None: + raise HTTPException(status_code=409, detail="Mission Plan is no longer available") + objective = await db.get(DiscoveryObjective, plan.objective_id) + current_boundary = await db.scalar( + select(ApprovedCollectionBoundary) + .where(ApprovedCollectionBoundary.objective_id == plan.objective_id) + .order_by(ApprovedCollectionBoundary.version.desc()) + ) + if ( + objective is None + or objective.status != "active" + or current_boundary is None + or plan.boundary_id != current_boundary.id + ): + raise HTTPException( + status_code=409, + detail="Plan is no longer permitted by the current approved boundary", + ) return mission @@ -88,6 +113,7 @@ async def _persist_signal_drafts( { "id": uuid.uuid4(), "mission_run_id": run.id, + "source_id": uuid.UUID(run.input_snapshot["source"]["id"]), "lineage_key": draft.lineage_key, "raw_artifact_key": draft.raw_artifact_key, "source_label": draft.source_label, diff --git a/apps/api/routers/acquisition_missions.py b/apps/api/routers/acquisition_missions.py index 7fc6303..048609d 100644 --- a/apps/api/routers/acquisition_missions.py +++ b/apps/api/routers/acquisition_missions.py @@ -15,6 +15,11 @@ AcquisitionMissionResponse, ) from packages.storage.models.acquisition_mission import AcquisitionMission +from packages.storage.models.acquisition_plan import AcquisitionPlan +from packages.storage.models.discovery_objective import ( + ApprovedCollectionBoundary, + DiscoveryObjective, +) from packages.storage.models.source_config_version import SourceConfigVersion router = APIRouter() @@ -55,6 +60,29 @@ async def create_acquisition_mission( ), ) + if body.acquisition_plan_id is not None: + plan = await db.scalar( + select(AcquisitionPlan).where(AcquisitionPlan.id == body.acquisition_plan_id) + ) + if plan is None or str(body.source_id) not in plan.selected_source_ids: + raise HTTPException( + status_code=422, + detail="Mission source is not selected by the plan", + ) + objective = await db.scalar( + select(DiscoveryObjective).where(DiscoveryObjective.id == plan.objective_id) + ) + current_boundary = await db.scalar( + select(ApprovedCollectionBoundary) + .where(ApprovedCollectionBoundary.objective_id == plan.objective_id) + .order_by(ApprovedCollectionBoundary.version.desc()) + ) + if objective.status != "active" or plan.boundary_id != current_boundary.id: + raise HTTPException( + status_code=409, + detail="Plan is no longer permitted by the current approved boundary", + ) + mission = AcquisitionMission( **body.model_dump(), source_config_version=config, diff --git a/apps/api/routers/agent_runs.py b/apps/api/routers/agent_runs.py index 7a5bd1d..80e5040 100644 --- a/apps/api/routers/agent_runs.py +++ b/apps/api/routers/agent_runs.py @@ -1,5 +1,6 @@ """Run deterministic proposal agents against an immutable evidence bundle.""" +import asyncio import hashlib import json import uuid @@ -13,13 +14,65 @@ from apps.api.dependencies import get_db from apps.api.schemas.agent_run import AgentRunCreate, AgentRunOperatorDecision, AgentRunResponse from apps.api.services.pi_runtime import PiRuntimeError, run_pi_proposal +from packages.storage.models.acquisition_plan import AcquisitionPlan from packages.storage.models.agent_run import AgentRun +from packages.storage.models.discovery_objective import ( + ApprovedCollectionBoundary, + DiscoveryObjective, +) from packages.storage.models.external_signal import ExternalSignal router = APIRouter() +objective_router = APIRouter() _TOOL_ALLOWLIST: list[str] = [] +def _objective_input_context( + objective: DiscoveryObjective, + boundary: ApprovedCollectionBoundary, + plan: AcquisitionPlan | None, + proposal_type: str, +) -> dict: + """Freeze the read-only Objective and Boundary the Agent was allowed to see.""" + return { + "objective": { + "id": str(objective.id), + "title": objective.title, + "question": objective.question, + "status": objective.status, + "resource_stop_conditions": objective.resource_stop_conditions, + "evidence_stop_conditions": objective.evidence_stop_conditions, + "decision_stop_conditions": objective.decision_stop_conditions, + }, + "boundary": { + "id": str(boundary.id), + "version": boundary.version, + "approved_source_ids": boundary.approved_source_ids, + "tool_allowlist": boundary.tool_allowlist, + "request_limit": boundary.request_limit, + "time_budget_minutes": boundary.time_budget_minutes, + "cost_budget_cents": boundary.cost_budget_cents, + "credential_scope": boundary.credential_scope, + "evidence_conditions": boundary.evidence_conditions, + }, + "plan": ( + { + "id": str(plan.id), + "version": plan.version, + "question": plan.question, + "selected_source_ids": plan.selected_source_ids, + "counterevidence_target": plan.counterevidence_target, + "request_budget": plan.request_budget, + "time_budget_minutes": plan.time_budget_minutes, + "cost_budget_cents": plan.cost_budget_cents, + } + if plan is not None + else None + ), + "proposal_type": proposal_type, + } + + async def _run_or_404(db: AsyncSession, run_id: uuid.UUID) -> AgentRun: run = await db.get(AgentRun, run_id) if run is None: @@ -27,11 +80,91 @@ async def _run_or_404(db: AsyncSession, run_id: uuid.UUID) -> AgentRun: return run -def _bundle_hash(bundle: list[dict]) -> str: +def _bundle_hash(bundle: object) -> str: encoded = json.dumps(bundle, ensure_ascii=False, sort_keys=True, separators=(",", ":")) return hashlib.sha256(encoded.encode()).hexdigest() +def _structured_assessment_proposal(runtime_output: dict, evidence_ids: list[str]) -> dict: + """Expose a stable proposal contract; malformed model text becomes an explicit unknown.""" + fallback = { + "contract": "discovery_assessment_proposal.v1", + "kind": "unknown", + "statement": "The Agent output cannot support an assessment proposal yet.", + "evidence_ids": evidence_ids, + "assessment_ids": [], + "unknowns": ["Pi output did not satisfy the assessment proposal contract."], + "coverage_gaps": [], + "recommendation": "Review the cited evidence or run a bounded next acquisition plan.", + "status": "unknown", + } + try: + raw = json.loads(str(runtime_output.get("raw_output", ""))) + except json.JSONDecodeError: + return fallback + allowed_kinds = { + "support", + "counterevidence", + "unknown", + "coverage_gap", + "blocked", + "recommendation", + } + if ( + not isinstance(raw, dict) + or raw.get("kind") not in allowed_kinds + or not isinstance(raw.get("statement"), str) + or not raw["statement"].strip() + or not isinstance(raw.get("unknowns", []), list) + ): + return fallback + cited_ids = raw.get("evidence_ids", evidence_ids) + if not isinstance(cited_ids, list) or set(cited_ids) - set(evidence_ids): + return fallback + return { + "contract": "discovery_assessment_proposal.v1", + "kind": raw["kind"], + "statement": raw["statement"].strip(), + "evidence_ids": cited_ids, + "assessment_ids": [], + "unknowns": raw.get("unknowns", []), + "coverage_gaps": raw.get("coverage_gaps", []), + "recommendation": raw.get("recommendation"), + "status": "proposed", + } + + +def _structured_plan_revision_proposal(runtime_output: dict, plan: dict) -> dict: + """A malformed plan recommendation never becomes a runnable Plan Revision.""" + fallback = { + "contract": "acquisition_plan_revision_proposal.v1", + "predecessor_plan_id": plan["id"], + "proposed_delta": {}, + "reason": "Pi output cannot support a Plan Revision proposal yet.", + "coverage_gaps": ["Pi output did not satisfy the plan revision contract."], + "status": "unknown", + } + try: + raw = json.loads(str(runtime_output.get("raw_output", ""))) + except json.JSONDecodeError: + return fallback + if ( + not isinstance(raw, dict) + or not isinstance(raw.get("proposed_delta"), dict) + or not isinstance(raw.get("reason"), str) + or not raw["reason"].strip() + ): + return fallback + return { + "contract": "acquisition_plan_revision_proposal.v1", + "predecessor_plan_id": plan["id"], + "proposed_delta": raw["proposed_delta"], + "reason": raw["reason"].strip(), + "coverage_gaps": raw.get("coverage_gaps", []), + "status": "proposed", + } + + @router.get("/{run_id}", response_model=AgentRunResponse) async def get_agent_run(run_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)]): return await _run_or_404(db, run_id) @@ -43,10 +176,20 @@ async def create_agent_run( response: Response, db: Annotated[AsyncSession, Depends(get_db)], ): + if body.acquisition_plan_id is not None or body.proposal_type == "plan_revision": + raise HTTPException( + status_code=422, + detail="A plan-bound Agent Run must use a Discovery Objective endpoint", + ) existing = await db.scalar( select(AgentRun).where(AgentRun.idempotency_key == body.idempotency_key) ) if existing is not None: + if existing.objective_id is not None: + raise HTTPException( + status_code=409, + detail="Agent Run idempotency key belongs to a Discovery Objective", + ) response.status_code = 200 return existing signals = list( @@ -78,6 +221,7 @@ async def create_agent_run( "max_tool_calls": body.max_tool_calls, "max_tokens": body.max_tokens, "max_cost_cents": body.max_cost_cents, + "max_time_minutes": body.max_time_minutes, }, tool_allowlist=_TOOL_ALLOWLIST, tool_audit=[], @@ -92,6 +236,125 @@ async def create_agent_run( return run +@objective_router.post( + "/{objective_id}/agent-runs", response_model=AgentRunResponse, status_code=201 +) +async def create_objective_agent_run( + objective_id: uuid.UUID, + body: AgentRunCreate, + response: Response, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.get(DiscoveryObjective, objective_id) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status != "active": + raise HTTPException( + status_code=409, detail="Only an active objective can run the Discovery Agent" + ) + boundary = await db.scalar( + select(ApprovedCollectionBoundary) + .where(ApprovedCollectionBoundary.objective_id == objective_id) + .order_by(ApprovedCollectionBoundary.version.desc()) + ) + if ( + body.max_tool_calls > boundary.request_limit + or body.max_cost_cents > boundary.cost_budget_cents + or body.max_time_minutes > boundary.time_budget_minutes + ): + raise HTTPException(status_code=422, detail="Agent budget is outside the approved boundary") + existing = await db.scalar( + select(AgentRun).where(AgentRun.idempotency_key == body.idempotency_key) + ) + if existing is not None: + if existing.objective_id != objective_id: + raise HTTPException( + status_code=409, + detail="Agent Run idempotency key belongs to another Objective", + ) + response.status_code = 200 + return existing + plan = None + if body.acquisition_plan_id is not None: + plan = await db.scalar( + select(AcquisitionPlan).where(AcquisitionPlan.id == body.acquisition_plan_id) + ) + if plan is None or plan.objective_id != objective_id or plan.boundary_id != boundary.id: + raise HTTPException( + status_code=422, + detail="Agent Run plan is outside this Objective's current boundary", + ) + elif body.proposal_type == "plan_revision": + raise HTTPException( + status_code=422, + detail="A plan revision proposal requires an Acquisition Plan", + ) + signals = list( + await db.scalars( + select(ExternalSignal).where(ExternalSignal.id.in_(body.evidence_signal_ids)) + ) + ) + if len(signals) != len(body.evidence_signal_ids): + raise HTTPException(status_code=422, detail="Every evidence signal must exist") + approved_source_ids = set(boundary.approved_source_ids) + if any( + signal.source_id is None or str(signal.source_id) not in approved_source_ids + for signal in signals + ): + raise HTTPException( + status_code=422, + detail="Every evidence signal must belong to the current approved boundary", + ) + if plan is not None and any( + str(signal.source_id) not in set(plan.selected_source_ids) for signal in signals + ): + raise HTTPException( + status_code=422, + detail="Every evidence signal must belong to the Agent Run's Acquisition Plan", + ) + bundle = [ + { + "signal_id": str(signal.id), + "source_id": str(signal.source_id), + "source_label": signal.source_label, + "source_uri": signal.source_uri, + "original_material": signal.original_material, + "observation": signal.observation, + } + for signal in signals + ] + input_context = _objective_input_context(objective, boundary, plan, body.proposal_type) + run = AgentRun( + objective_id=objective_id, + boundary_id=boundary.id, + boundary_version=boundary.version, + acquisition_plan_id=plan.id if plan is not None else None, + input_context=input_context, + idempotency_key=body.idempotency_key, + task_instruction=body.task_instruction, + evidence_bundle=bundle, + evidence_bundle_hash=_bundle_hash({"input_context": input_context, "evidence": bundle}), + model_version=body.model_version, + prompt_version=body.prompt_version, + budgets={ + "max_tool_calls": body.max_tool_calls, + "max_tokens": body.max_tokens, + "max_cost_cents": body.max_cost_cents, + "max_time_minutes": body.max_time_minutes, + }, + tool_allowlist=boundary.tool_allowlist, + tool_audit=[], + usage={"tool_calls": 0, "tokens": 0, "cost_cents": 0}, + errors=[], + operator_changes=[], + status="created", + ) + db.add(run) + await db.commit() + await db.refresh(run) + return run + + @router.post("/{run_id}/execute", response_model=AgentRunResponse) async def execute_agent_run(run_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)]): run = await _run_or_404(db, run_id) @@ -99,16 +362,51 @@ async def execute_agent_run(run_id: uuid.UUID, db: Annotated[AsyncSession, Depen raise HTTPException(status_code=409, detail="Cancelled Agent Run cannot execute") if run.status == "completed": return run - try: - runtime_output = await run_pi_proposal( - run_id=str(run.id), - task_instruction=run.task_instruction, - evidence_bundle_hash=run.evidence_bundle_hash, - evidence_bundle=run.evidence_bundle, - model_version=run.model_version, - budgets=run.budgets, + if run.objective_id is not None: + objective = await db.get(DiscoveryObjective, run.objective_id) + current_boundary = await db.scalar( + select(ApprovedCollectionBoundary) + .where(ApprovedCollectionBoundary.objective_id == run.objective_id) + .order_by(ApprovedCollectionBoundary.version.desc()) ) + if ( + objective is None + or objective.status != "active" + or current_boundary is None + or current_boundary.id != run.boundary_id + or current_boundary.version != run.boundary_version + ): + raise HTTPException( + status_code=409, + detail="Discovery Agent run boundary is no longer active", + ) + try: + async with asyncio.timeout(run.budgets.get("max_time_minutes", 1) * 60): + runtime_output = await run_pi_proposal( + run_id=str(run.id), + task_instruction=run.task_instruction, + evidence_bundle_hash=run.evidence_bundle_hash, + evidence_bundle=( + [ + *run.evidence_bundle, + {"kind": "objective_context", "snapshot": run.input_context}, + ] + if run.input_context is not None + else run.evidence_bundle + ), + model_version=run.model_version, + budgets=run.budgets, + ) runtime_usage = runtime_output.pop("usage", {}) + if run.objective_id is not None: + runtime_output["proposal"] = ( + _structured_plan_revision_proposal(runtime_output, run.input_context["plan"]) + if run.input_context.get("proposal_type") == "plan_revision" + else _structured_assessment_proposal( + runtime_output, + [entry["signal_id"] for entry in run.evidence_bundle], + ) + ) run.output = runtime_output run.tool_audit = [ {"tool": "Pi Agent", "status": "completed", "policy": "no executable tools"} @@ -119,7 +417,7 @@ async def execute_agent_run(run_id: uuid.UUID, db: Annotated[AsyncSession, Depen "cost_cents": runtime_usage.get("cost_cents", 0), } run.status = "completed" - except PiRuntimeError as error: + except (PiRuntimeError, TimeoutError) as error: run.errors = [*run.errors, {"stage": "runtime", "error": str(error)}] run.status = "failed" run.completed_at = datetime.now(UTC) diff --git a/apps/api/routers/discovery_objectives.py b/apps/api/routers/discovery_objectives.py new file mode 100644 index 0000000..103f9c3 --- /dev/null +++ b/apps/api/routers/discovery_objectives.py @@ -0,0 +1,816 @@ +"""Create and read operator-bounded Discovery Objectives.""" + +import uuid +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from apps.api.dependencies import get_db +from apps.api.routers.need_issues import create_need_issue_from_accepted_signal +from apps.api.schemas.acquisition_mission_run import AcquisitionMissionRunResponse +from apps.api.schemas.agent_run import AgentRunResponse +from apps.api.schemas.discovery_objective import ( + AcquisitionPlanCreate, + AcquisitionPlanResponse, + ApprovedCollectionBoundaryResponse, + BoundaryPatch, + DecisionRecordCreate, + DiscoveryAssessmentCreate, + DiscoveryAssessmentResponse, + DiscoveryDecisionRecordResponse, + DiscoveryObjectiveCreate, + DiscoveryObjectiveResponse, + DiscoveryObjectiveWorkspaceResponse, + NeedHypothesisCreate, + NeedHypothesisPromotion, + NeedHypothesisResponse, + ObjectiveBlockRequest, + OperatorApprovalCreate, + OperatorApprovalDecision, + OperatorApprovalResponse, + OperatorBoundaryRevisionCreate, + OperatorBoundaryRevisionResponse, + OutcomeFeedbackCreate, + OutcomeFeedbackResponse, + PlanRevisionResponse, +) +from apps.api.schemas.need_issue import NeedIssueFromAcceptedSignalCreate +from packages.storage.models.acquisition_mission_run import AcquisitionMissionRun +from packages.storage.models.acquisition_plan import AcquisitionPlan, PlanRevision +from packages.storage.models.agent_run import AgentRun +from packages.storage.models.discovery_assessment import DiscoveryAssessment, NeedHypothesis +from packages.storage.models.discovery_decision import DiscoveryDecisionRecord, OutcomeFeedback +from packages.storage.models.discovery_objective import ( + ApprovedCollectionBoundary, + DiscoveryObjective, + OperatorApproval, + OperatorBoundaryRevision, +) +from packages.storage.models.external_signal import ExternalSignal +from packages.storage.models.source import Source + +router = APIRouter() + + +def _objective_with_boundaries(objective_id: uuid.UUID): + return ( + select(DiscoveryObjective) + .options(selectinload(DiscoveryObjective.boundaries)) + .where(DiscoveryObjective.id == objective_id) + ) + + +def _plan_with_missions(plan_id: uuid.UUID): + return ( + select(AcquisitionPlan) + .options(selectinload(AcquisitionPlan.missions)) + .where(AcquisitionPlan.id == plan_id) + ) + + +def _response_for(objective: DiscoveryObjective) -> DiscoveryObjectiveResponse: + current_boundary = objective.boundaries[-1] + return DiscoveryObjectiveResponse( + id=objective.id, + title=objective.title, + question=objective.question, + resource_stop_conditions=objective.resource_stop_conditions, + evidence_stop_conditions=objective.evidence_stop_conditions, + decision_stop_conditions=objective.decision_stop_conditions, + status=objective.status, + created_at=objective.created_at, + updated_at=objective.updated_at, + current_boundary=ApprovedCollectionBoundaryResponse.model_validate(current_boundary), + ) + + +def _boundary_response(boundary: ApprovedCollectionBoundary) -> ApprovedCollectionBoundaryResponse: + return ApprovedCollectionBoundaryResponse.model_validate(boundary) + + +def _revision_response( + revision: OperatorBoundaryRevision, boundary_version: int +) -> OperatorBoundaryRevisionResponse: + return OperatorBoundaryRevisionResponse( + id=revision.id, + objective_id=revision.objective_id, + boundary_id=revision.boundary_id, + boundary_version=boundary_version, + approval_id=revision.approval_id, + operator=revision.operator, + reason=revision.reason, + boundary_patch=revision.boundary_patch, + created_at=revision.created_at, + ) + + +async def _plan_response(db: AsyncSession, plan: AcquisitionPlan) -> AcquisitionPlanResponse: + revision = await db.scalar(select(PlanRevision).where(PlanRevision.plan_id == plan.id)) + boundary = await db.scalar( + select(ApprovedCollectionBoundary).where(ApprovedCollectionBoundary.id == plan.boundary_id) + ) + mission_ids = [mission.id for mission in plan.missions] + runs = ( + list( + ( + await db.scalars( + select(AcquisitionMissionRun) + .where(AcquisitionMissionRun.mission_id.in_(mission_ids)) + .order_by(AcquisitionMissionRun.started_at.desc()) + ) + ).all() + ) + if mission_ids + else [] + ) + mission_runs: dict[uuid.UUID, list[AcquisitionMissionRunResponse]] = { + mission_id: [] for mission_id in mission_ids + } + for run in runs: + mission_runs[run.mission_id].append(AcquisitionMissionRunResponse.model_validate(run)) + return AcquisitionPlanResponse( + id=plan.id, + objective_id=plan.objective_id, + boundary_id=plan.boundary_id, + boundary_version=boundary.version, + version=plan.version, + question=plan.question, + selected_source_ids=plan.selected_source_ids, + counterevidence_target=plan.counterevidence_target, + request_budget=plan.request_budget, + time_budget_minutes=plan.time_budget_minutes, + cost_budget_cents=plan.cost_budget_cents, + created_at=plan.created_at, + predecessor_plan_id=revision.predecessor_plan_id if revision else None, + revision=( + PlanRevisionResponse( + id=revision.id, + predecessor_plan_id=revision.predecessor_plan_id, + reason=revision.reason, + delta=revision.delta, + created_at=revision.created_at, + ) + if revision + else None + ), + missions=mission_ids, + mission_runs=mission_runs, + ) + + +async def _decision_record_response( + db: AsyncSession, record: DiscoveryDecisionRecord +) -> DiscoveryDecisionRecordResponse: + outcomes = list( + ( + await db.scalars( + select(OutcomeFeedback) + .where(OutcomeFeedback.decision_record_id == record.id) + .order_by(OutcomeFeedback.created_at) + ) + ).all() + ) + return DiscoveryDecisionRecordResponse( + id=record.id, + objective_id=record.objective_id, + decision=record.decision, + reason=record.reason, + support_assessment_ids=record.support_assessment_ids, + counter_assessment_ids=record.counter_assessment_ids, + unknowns=record.unknowns, + resource_usage=record.resource_usage, + created_at=record.created_at, + outcomes=[OutcomeFeedbackResponse.model_validate(outcome) for outcome in outcomes], + ) + + +async def _validate_source_ids(db: AsyncSession, source_ids: list[uuid.UUID]) -> None: + if len(set(source_ids)) != len(source_ids): + raise HTTPException(status_code=422, detail="Approved source IDs must not repeat") + found_source_ids = set( + (await db.scalars(select(Source.id).where(Source.id.in_(source_ids)))).all() + ) + if found_source_ids != set(source_ids): + raise HTTPException(status_code=422, detail="An approved source does not exist") + + +async def _apply_boundary_patch( + db: AsyncSession, + objective: DiscoveryObjective, + current: ApprovedCollectionBoundary, + patch: BoundaryPatch, +) -> tuple[ApprovedCollectionBoundary, dict]: + values = patch.model_dump(exclude_none=True) + if not values: + raise HTTPException(status_code=422, detail="Boundary revision requires a material delta") + if "approved_source_ids" in values: + await _validate_source_ids(db, values["approved_source_ids"]) + values["approved_source_ids"] = [ + str(source_id) for source_id in values["approved_source_ids"] + ] + + current_values = { + "approved_source_ids": current.approved_source_ids, + "tool_allowlist": current.tool_allowlist, + "request_limit": current.request_limit, + "time_budget_minutes": current.time_budget_minutes, + "cost_budget_cents": current.cost_budget_cents, + "credential_scope": current.credential_scope, + "evidence_conditions": current.evidence_conditions, + } + if all(current_values[key] == value for key, value in values.items()): + raise HTTPException(status_code=422, detail="Boundary revision must change an allowance") + + next_values = current_values | values + boundary = ApprovedCollectionBoundary( + objective=objective, + version=current.version + 1, + **next_values, + ) + db.add(boundary) + await db.flush() + return boundary, values + + +@router.post("", response_model=DiscoveryObjectiveResponse, status_code=201) +async def create_discovery_objective( + body: DiscoveryObjectiveCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + source_ids = body.initial_boundary.approved_source_ids + await _validate_source_ids(db, source_ids) + + objective = DiscoveryObjective( + title=body.title, + question=body.question, + resource_stop_conditions=body.resource_stop_conditions, + evidence_stop_conditions=body.evidence_stop_conditions, + decision_stop_conditions=body.decision_stop_conditions, + ) + boundary = ApprovedCollectionBoundary( + objective=objective, + version=1, + approved_source_ids=[str(source_id) for source_id in source_ids], + tool_allowlist=body.initial_boundary.tool_allowlist, + request_limit=body.initial_boundary.request_limit, + time_budget_minutes=body.initial_boundary.time_budget_minutes, + cost_budget_cents=body.initial_boundary.cost_budget_cents, + credential_scope=body.initial_boundary.credential_scope, + evidence_conditions=body.initial_boundary.evidence_conditions, + ) + db.add_all([objective, boundary]) + await db.commit() + saved = await db.scalar(_objective_with_boundaries(objective.id)) + return _response_for(saved) + + +@router.get("/{objective_id}", response_model=DiscoveryObjectiveResponse) +async def get_discovery_objective( + objective_id: uuid.UUID, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + return _response_for(objective) + + +@router.get("/{objective_id}/workspace", response_model=DiscoveryObjectiveWorkspaceResponse) +async def get_discovery_objective_workspace( + objective_id: uuid.UUID, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + response = _response_for(objective) + plans = list( + ( + await db.scalars( + select(AcquisitionPlan) + .options(selectinload(AcquisitionPlan.missions)) + .where(AcquisitionPlan.objective_id == objective_id) + .order_by(AcquisitionPlan.version.desc()) + ) + ).all() + ) + decision_record = await db.scalar( + select(DiscoveryDecisionRecord).where(DiscoveryDecisionRecord.objective_id == objective_id) + ) + agent_runs = list( + ( + await db.scalars( + select(AgentRun) + .where(AgentRun.objective_id == objective_id) + .order_by(AgentRun.created_at.desc()) + ) + ).all() + ) + evidence_by_id = { + evidence["signal_id"]: evidence + for run in agent_runs + for evidence in run.evidence_bundle + if "signal_id" in evidence + } + return DiscoveryObjectiveWorkspaceResponse( + objective=response, + current_boundary=response.current_boundary, + plans=[await _plan_response(db, plan) for plan in plans], + assessments=list( + ( + await db.scalars( + select(DiscoveryAssessment) + .where(DiscoveryAssessment.objective_id == objective_id) + .order_by(DiscoveryAssessment.version.desc()) + ) + ).all() + ), + pending_approvals=list( + ( + await db.scalars( + select(OperatorApproval) + .where( + OperatorApproval.objective_id == objective_id, + OperatorApproval.status == "pending", + ) + .order_by(OperatorApproval.created_at.desc()) + ) + ).all() + ), + boundary_revisions=[ + _revision_response(revision, boundary.version) + for revision, boundary in ( + await db.execute( + select(OperatorBoundaryRevision, ApprovedCollectionBoundary) + .join( + ApprovedCollectionBoundary, + OperatorBoundaryRevision.boundary_id == ApprovedCollectionBoundary.id, + ) + .where(OperatorBoundaryRevision.objective_id == objective_id) + .order_by(OperatorBoundaryRevision.created_at.desc()) + ) + ).all() + ], + agent_runs=[AgentRunResponse.model_validate(run) for run in agent_runs], + evidence_bundle=list(evidence_by_id.values()), + need_hypotheses=list( + ( + await db.scalars( + select(NeedHypothesis) + .where(NeedHypothesis.objective_id == objective_id) + .order_by(NeedHypothesis.created_at.desc()) + ) + ).all() + ), + decision_record=( + await _decision_record_response(db, decision_record) + if decision_record is not None + else None + ), + ) + + +@router.post( + "/{objective_id}/assessments", response_model=DiscoveryAssessmentResponse, status_code=201 +) +async def create_discovery_assessment( + objective_id: uuid.UUID, + body: DiscoveryAssessmentCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + if not body.evidence_ids and not body.assessment_ids: + raise HTTPException( + status_code=422, detail="Assessment requires evidence or upstream assessment citations" + ) + objective = await db.scalar( + select(DiscoveryObjective).where(DiscoveryObjective.id == objective_id) + ) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + evidence_ids = set(body.evidence_ids) + if evidence_ids and len( + ( + await db.scalars(select(ExternalSignal.id).where(ExternalSignal.id.in_(evidence_ids))) + ).all() + ) != len(evidence_ids): + raise HTTPException( + status_code=422, detail="Assessment cites an unknown evidence candidate" + ) + upstream_assessment_ids = set(body.assessment_ids) + if upstream_assessment_ids and len( + ( + await db.scalars( + select(DiscoveryAssessment.id).where( + DiscoveryAssessment.id.in_(upstream_assessment_ids), + DiscoveryAssessment.objective_id == objective_id, + ) + ) + ).all() + ) != len(upstream_assessment_ids): + raise HTTPException( + status_code=422, + detail="Assessment cites an upstream Assessment outside this Objective", + ) + version = ( + await db.scalar( + select(func.max(DiscoveryAssessment.version)).where( + DiscoveryAssessment.objective_id == objective_id + ) + ) + or 0 + ) + 1 + assessment = DiscoveryAssessment( + objective_id=objective_id, version=version, **body.model_dump(mode="json") + ) + db.add(assessment) + await db.commit() + return assessment + + +@router.post( + "/{objective_id}/need-hypotheses", response_model=NeedHypothesisResponse, status_code=201 +) +async def create_need_hypothesis( + objective_id: uuid.UUID, + body: NeedHypothesisCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + assessments = list( + ( + await db.scalars( + select(DiscoveryAssessment).where( + DiscoveryAssessment.id.in_(body.support_assessment_ids), + DiscoveryAssessment.objective_id == objective_id, + ) + ) + ).all() + ) + if len(assessments) != len(body.support_assessment_ids) or not all( + a.kind == "support" and (a.evidence_ids or a.assessment_ids) for a in assessments + ): + raise HTTPException( + status_code=422, detail="Need Hypothesis requires cited support assessments" + ) + hypothesis = NeedHypothesis(objective_id=objective_id, **body.model_dump(mode="json")) + db.add(hypothesis) + await db.commit() + return hypothesis + + +@router.post("/{objective_id}/need-hypotheses/{hypothesis_id}/promote", status_code=201) +async def promote_need_hypothesis( + objective_id: uuid.UUID, + hypothesis_id: uuid.UUID, + body: NeedHypothesisPromotion, + db: Annotated[AsyncSession, Depends(get_db)], +): + hypothesis = await db.scalar( + select(NeedHypothesis).where( + NeedHypothesis.id == hypothesis_id, NeedHypothesis.objective_id == objective_id + ) + ) + if hypothesis is None: + raise HTTPException(status_code=404, detail="Need Hypothesis not found") + if hypothesis.status != "draft": + raise HTTPException(status_code=409, detail="Need Hypothesis is not promotable") + assessments = list( + ( + await db.scalars( + select(DiscoveryAssessment).where( + DiscoveryAssessment.id.in_( + [uuid.UUID(value) for value in hypothesis.support_assessment_ids] + ) + ) + ) + ).all() + ) + signal_ids = [ + uuid.UUID(value) for assessment in assessments for value in assessment.evidence_ids + ] + signal = await db.scalar( + select(ExternalSignal).where( + ExternalSignal.id.in_(signal_ids), ExternalSignal.status == "accepted" + ) + ) + if signal is None: + raise HTTPException( + status_code=422, detail="Promotion requires an accepted evidence candidate" + ) + need = await create_need_issue_from_accepted_signal( + NeedIssueFromAcceptedSignalCreate( + external_signal_id=signal.id, + title=hypothesis.title, + target_actor=hypothesis.target_actor, + context=hypothesis.context, + problem=hypothesis.problem, + desired_outcome=hypothesis.desired_outcome, + workaround=hypothesis.workaround, + unknowns=hypothesis.unknowns, + next_validation_action=hypothesis.next_validation_action, + ), + db, + ) + hypothesis.status = "promoted" + hypothesis.promoted_need_issue_id = need.id + await db.commit() + return need + + +@router.post( + "/{objective_id}/decision-records", + response_model=DiscoveryDecisionRecordResponse, + status_code=201, +) +async def close_with_decision_record( + objective_id: uuid.UUID, + body: DecisionRecordCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.get(DiscoveryObjective, objective_id) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status == "completed": + raise HTTPException(status_code=409, detail="Discovery Objective already closed") + cited_assessment_ids = set(body.support_assessment_ids + body.counter_assessment_ids) + if cited_assessment_ids: + citations = list( + ( + await db.scalars( + select(DiscoveryAssessment).where( + DiscoveryAssessment.id.in_(cited_assessment_ids), + DiscoveryAssessment.objective_id == objective_id, + ) + ) + ).all() + ) + if len(citations) != len(cited_assessment_ids): + raise HTTPException( + status_code=422, + detail="Decision Record cites an assessment outside this objective", + ) + record = DiscoveryDecisionRecord(objective_id=objective_id, **body.model_dump(mode="json")) + objective.status = "completed" + db.add(record) + await db.commit() + return await _decision_record_response(db, record) + + +@router.post( + "/{objective_id}/decision-records/{record_id}/outcomes", + response_model=OutcomeFeedbackResponse, + status_code=201, +) +async def append_outcome_feedback( + objective_id: uuid.UUID, + record_id: uuid.UUID, + body: OutcomeFeedbackCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + record = await db.scalar( + select(DiscoveryDecisionRecord).where( + DiscoveryDecisionRecord.id == record_id, + DiscoveryDecisionRecord.objective_id == objective_id, + ) + ) + if record is None: + raise HTTPException(status_code=404, detail="Discovery Decision Record not found") + outcome = OutcomeFeedback(decision_record_id=record_id, **body.model_dump()) + db.add(outcome) + await db.commit() + return OutcomeFeedbackResponse.model_validate(outcome) + + +@router.post("/{objective_id}/plans", response_model=AcquisitionPlanResponse, status_code=201) +async def create_acquisition_plan( + objective_id: uuid.UUID, + body: AcquisitionPlanCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status != "active": + raise HTTPException(status_code=409, detail="Only an active objective can create a plan") + + current_boundary = objective.boundaries[-1] + selected_source_ids = {str(source_id) for source_id in body.selected_source_ids} + if len(selected_source_ids) != len(body.selected_source_ids): + raise HTTPException(status_code=422, detail="Plan source IDs must not repeat") + if not selected_source_ids.issubset(set(current_boundary.approved_source_ids)): + raise HTTPException( + status_code=422, + detail="Plan sources are outside the approved boundary", + ) + if ( + body.request_budget > current_boundary.request_limit + or body.time_budget_minutes > current_boundary.time_budget_minutes + or body.cost_budget_cents > current_boundary.cost_budget_cents + ): + raise HTTPException(status_code=422, detail="Plan budget is outside the approved boundary") + + latest_version = await db.scalar( + select(func.max(AcquisitionPlan.version)).where( + AcquisitionPlan.objective_id == objective_id + ) + ) + if body.predecessor_plan_id is not None: + predecessor = await db.scalar(_plan_with_missions(body.predecessor_plan_id)) + if predecessor is None or predecessor.objective_id != objective_id: + raise HTTPException( + status_code=422, + detail="Plan predecessor does not belong to this objective", + ) + if not body.revision_reason or body.revision_delta is None: + raise HTTPException(status_code=422, detail="Plan revision requires a reason and delta") + elif body.revision_reason or body.revision_delta is not None: + raise HTTPException(status_code=422, detail="Initial plan cannot include a revision record") + + plan = AcquisitionPlan( + objective_id=objective_id, + boundary_id=current_boundary.id, + version=(latest_version or 0) + 1, + question=body.question, + selected_source_ids=[str(source_id) for source_id in body.selected_source_ids], + counterevidence_target=body.counterevidence_target, + request_budget=body.request_budget, + time_budget_minutes=body.time_budget_minutes, + cost_budget_cents=body.cost_budget_cents, + ) + db.add(plan) + await db.flush() + if body.predecessor_plan_id is not None: + revision = PlanRevision( + plan_id=plan.id, + predecessor_plan_id=body.predecessor_plan_id, + reason=body.revision_reason, + delta=body.revision_delta, + ) + db.add(revision) + await db.commit() + saved = await db.scalar(_plan_with_missions(plan.id)) + return await _plan_response(db, saved) + + +@router.post("/{objective_id}/approvals", response_model=OperatorApprovalResponse, status_code=201) +async def request_operator_approval( + objective_id: uuid.UUID, + body: OperatorApprovalCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status != "active": + raise HTTPException(status_code=409, detail="Only an active objective can request approval") + if not body.requested_boundary_patch.model_dump(exclude_none=True): + raise HTTPException(status_code=422, detail="Approval request requires a boundary delta") + + approval = OperatorApproval( + objective_id=objective_id, + request_type=body.request_type, + reason=body.reason, + requested_boundary_patch=body.requested_boundary_patch.model_dump( + exclude_none=True, mode="json" + ), + ) + objective.status = "pending_approval" + db.add(approval) + await db.commit() + return approval + + +@router.post( + "/{objective_id}/approvals/{approval_id}/approve", + response_model=OperatorApprovalResponse, +) +async def approve_operator_request( + objective_id: uuid.UUID, + approval_id: uuid.UUID, + body: OperatorApprovalDecision, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + approval = await db.scalar( + select(OperatorApproval).where( + OperatorApproval.id == approval_id, + OperatorApproval.objective_id == objective_id, + ) + ) + if objective is None or approval is None: + raise HTTPException(status_code=404, detail="Approval request not found") + if objective.status != "pending_approval" or approval.status != "pending": + raise HTTPException(status_code=409, detail="Approval request is not awaiting a decision") + + boundary, normalized_patch = await _apply_boundary_patch( + db, + objective, + objective.boundaries[-1], + BoundaryPatch.model_validate(approval.requested_boundary_patch), + ) + revision = OperatorBoundaryRevision( + objective_id=objective_id, + boundary_id=boundary.id, + approval_id=approval.id, + operator=body.operator, + reason=body.reason, + boundary_patch=normalized_patch, + ) + approval.status = "approved" + approval.operator = body.operator + approval.decision_reason = body.reason + approval.decided_at = datetime.now(UTC) + objective.status = "active" + db.add(revision) + await db.commit() + return approval + + +@router.post( + "/{objective_id}/approvals/{approval_id}/reject", + response_model=OperatorApprovalResponse, +) +async def reject_operator_request( + objective_id: uuid.UUID, + approval_id: uuid.UUID, + body: OperatorApprovalDecision, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar( + select(DiscoveryObjective).where(DiscoveryObjective.id == objective_id) + ) + approval = await db.scalar( + select(OperatorApproval).where( + OperatorApproval.id == approval_id, + OperatorApproval.objective_id == objective_id, + ) + ) + if objective is None or approval is None: + raise HTTPException(status_code=404, detail="Approval request not found") + if objective.status != "pending_approval" or approval.status != "pending": + raise HTTPException(status_code=409, detail="Approval request is not awaiting a decision") + + approval.status = "rejected" + approval.operator = body.operator + approval.decision_reason = body.reason + approval.decided_at = datetime.now(UTC) + objective.status = "active" + await db.commit() + return approval + + +@router.post("/{objective_id}/block", response_model=DiscoveryObjectiveResponse) +async def block_discovery_objective( + objective_id: uuid.UUID, + body: ObjectiveBlockRequest, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status != "active": + raise HTTPException(status_code=409, detail="Only an active objective can become blocked") + objective.status = "blocked" + objective.block_reason = body.reason + await db.commit() + blocked = await db.scalar(_objective_with_boundaries(objective_id)) + return _response_for(blocked) + + +@router.post( + "/{objective_id}/boundary-revisions", + response_model=OperatorBoundaryRevisionResponse, + status_code=201, +) +async def reactivate_with_boundary_revision( + objective_id: uuid.UUID, + body: OperatorBoundaryRevisionCreate, + db: Annotated[AsyncSession, Depends(get_db)], +): + objective = await db.scalar(_objective_with_boundaries(objective_id)) + if objective is None: + raise HTTPException(status_code=404, detail="Discovery Objective not found") + if objective.status != "blocked": + raise HTTPException( + status_code=409, + detail="Boundary revision can reactivate only a blocked objective", + ) + + boundary, normalized_patch = await _apply_boundary_patch( + db, objective, objective.boundaries[-1], body.boundary_patch + ) + revision = OperatorBoundaryRevision( + objective_id=objective_id, + boundary_id=boundary.id, + operator=body.operator, + reason=body.reason, + boundary_patch=normalized_patch, + ) + objective.status = "active" + objective.block_reason = None + db.add(revision) + await db.commit() + return _revision_response(revision, boundary.version) diff --git a/apps/api/routers/external_signals.py b/apps/api/routers/external_signals.py index 181382e..c45215b 100644 --- a/apps/api/routers/external_signals.py +++ b/apps/api/routers/external_signals.py @@ -16,6 +16,7 @@ SignalTriageCreate, ) from packages.storage.models.external_signal import ExternalSignal, SignalTriageEvent +from packages.storage.models.source import Source router = APIRouter() inbox_router = APIRouter() @@ -41,6 +42,8 @@ async def create_external_signal( body: ExternalSignalCreate, db: Annotated[AsyncSession, Depends(get_db)], ): + if body.source_id is not None and await db.get(Source, body.source_id) is None: + raise HTTPException(status_code=422, detail="External Signal source does not exist") signal = ExternalSignal(**body.model_dump()) db.add(signal) await db.commit() diff --git a/apps/api/schemas/acquisition_mission.py b/apps/api/schemas/acquisition_mission.py index 532bc58..cf252b3 100644 --- a/apps/api/schemas/acquisition_mission.py +++ b/apps/api/schemas/acquisition_mission.py @@ -27,11 +27,13 @@ class AcquisitionMissionFields(BaseModel): class AcquisitionMissionCreate(AcquisitionMissionFields): source_config_version_id: uuid.UUID + acquisition_plan_id: uuid.UUID | None = None class AcquisitionMissionResponse(AcquisitionMissionFields): id: uuid.UUID source_config_version_id: uuid.UUID | None + acquisition_plan_id: uuid.UUID | None source_config_version: SourceConfigVersionResponse | None status: str created_at: datetime diff --git a/apps/api/schemas/agent_run.py b/apps/api/schemas/agent_run.py index 833abda..3fc5017 100644 --- a/apps/api/schemas/agent_run.py +++ b/apps/api/schemas/agent_run.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime +from typing import Literal from pydantic import BaseModel, Field @@ -15,11 +16,19 @@ class AgentRunCreate(BaseModel): max_tool_calls: int = Field(gt=0, le=20) max_tokens: int = Field(gt=0, le=20_000) max_cost_cents: int = Field(ge=0, le=10_000) + max_time_minutes: int = Field(default=1, gt=0, le=240) + acquisition_plan_id: uuid.UUID | None = None + proposal_type: Literal["assessment", "plan_revision"] = "assessment" class AgentRunResponse(BaseModel): id: uuid.UUID idempotency_key: str + objective_id: uuid.UUID | None + boundary_id: uuid.UUID | None + boundary_version: int | None + acquisition_plan_id: uuid.UUID | None + input_context: dict | None task_instruction: str evidence_bundle: list[dict] evidence_bundle_hash: str diff --git a/apps/api/schemas/discovery_objective.py b/apps/api/schemas/discovery_objective.py new file mode 100644 index 0000000..7e9c205 --- /dev/null +++ b/apps/api/schemas/discovery_objective.py @@ -0,0 +1,251 @@ +"""Public contracts for bounded Discovery Objectives.""" + +import uuid +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, StringConstraints + +from apps.api.schemas.acquisition_mission_run import AcquisitionMissionRunResponse +from apps.api.schemas.agent_run import AgentRunResponse + +NonEmptyText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class InitialCollectionBoundary(BaseModel): + approved_source_ids: list[uuid.UUID] = Field(min_length=1) + tool_allowlist: list[NonEmptyText] = Field(min_length=1) + request_limit: int = Field(gt=0) + time_budget_minutes: int = Field(gt=0) + cost_budget_cents: int = Field(ge=0) + credential_scope: list[NonEmptyText] = Field(default_factory=list) + evidence_conditions: list[NonEmptyText] = Field(default_factory=list) + + +class BoundaryPatch(BaseModel): + approved_source_ids: list[uuid.UUID] | None = Field(default=None, min_length=1) + tool_allowlist: list[NonEmptyText] | None = Field(default=None, min_length=1) + request_limit: int | None = Field(default=None, gt=0) + time_budget_minutes: int | None = Field(default=None, gt=0) + cost_budget_cents: int | None = Field(default=None, ge=0) + credential_scope: list[NonEmptyText] | None = None + evidence_conditions: list[NonEmptyText] | None = None + + +class DiscoveryObjectiveCreate(BaseModel): + title: NonEmptyText = Field(max_length=200) + question: NonEmptyText + resource_stop_conditions: list[NonEmptyText] = Field(min_length=1) + evidence_stop_conditions: list[NonEmptyText] = Field(min_length=1) + decision_stop_conditions: list[NonEmptyText] = Field(min_length=1) + initial_boundary: InitialCollectionBoundary + + +class ApprovedCollectionBoundaryResponse(BaseModel): + id: uuid.UUID + objective_id: uuid.UUID + version: int + approved_source_ids: list[uuid.UUID] + tool_allowlist: list[str] + request_limit: int + time_budget_minutes: int + cost_budget_cents: int + credential_scope: list[str] + evidence_conditions: list[str] + created_at: datetime + + model_config = {"from_attributes": True} + + +class DiscoveryObjectiveResponse(BaseModel): + id: uuid.UUID + title: str + question: str + resource_stop_conditions: list[str] + evidence_stop_conditions: list[str] + decision_stop_conditions: list[str] + status: str + created_at: datetime + updated_at: datetime + current_boundary: ApprovedCollectionBoundaryResponse + + model_config = {"from_attributes": True} + + +class DiscoveryObjectiveWorkspaceResponse(BaseModel): + objective: DiscoveryObjectiveResponse + current_boundary: ApprovedCollectionBoundaryResponse + plans: list["AcquisitionPlanResponse"] = Field(default_factory=list) + assessments: list["DiscoveryAssessmentResponse"] = Field(default_factory=list) + pending_approvals: list["OperatorApprovalResponse"] = Field(default_factory=list) + boundary_revisions: list["OperatorBoundaryRevisionResponse"] = Field(default_factory=list) + agent_runs: list[AgentRunResponse] = Field(default_factory=list) + evidence_bundle: list[dict] = Field(default_factory=list) + need_hypotheses: list["NeedHypothesisResponse"] = Field(default_factory=list) + decision_record: "DiscoveryDecisionRecordResponse | None" = None + + +class OperatorApprovalCreate(BaseModel): + request_type: NonEmptyText = Field(max_length=64) + reason: NonEmptyText + requested_boundary_patch: BoundaryPatch + + +class OperatorApprovalDecision(BaseModel): + operator: NonEmptyText = Field(max_length=120) + reason: NonEmptyText + + +class OperatorApprovalResponse(BaseModel): + id: uuid.UUID + objective_id: uuid.UUID + request_type: str + reason: str + requested_boundary_patch: dict + status: str + operator: str | None + decision_reason: str | None + decided_at: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class OperatorBoundaryRevisionCreate(BaseModel): + operator: NonEmptyText = Field(max_length=120) + reason: NonEmptyText + boundary_patch: BoundaryPatch + + +class OperatorBoundaryRevisionResponse(BaseModel): + id: uuid.UUID + objective_id: uuid.UUID + boundary_id: uuid.UUID + boundary_version: int + approval_id: uuid.UUID | None + operator: str + reason: str + boundary_patch: dict + created_at: datetime + + +class ObjectiveBlockRequest(BaseModel): + reason: NonEmptyText + + +class AcquisitionPlanCreate(BaseModel): + question: NonEmptyText + selected_source_ids: list[uuid.UUID] = Field(min_length=1) + counterevidence_target: NonEmptyText + request_budget: int = Field(gt=0) + time_budget_minutes: int = Field(gt=0) + cost_budget_cents: int = Field(ge=0) + predecessor_plan_id: uuid.UUID | None = None + revision_reason: NonEmptyText | None = None + revision_delta: dict | None = None + + +class PlanRevisionResponse(BaseModel): + id: uuid.UUID + predecessor_plan_id: uuid.UUID + reason: str + delta: dict + created_at: datetime + + +class AcquisitionPlanResponse(BaseModel): + id: uuid.UUID + objective_id: uuid.UUID + boundary_id: uuid.UUID + boundary_version: int + version: int + question: str + selected_source_ids: list[uuid.UUID] + counterevidence_target: str + request_budget: int + time_budget_minutes: int + cost_budget_cents: int + created_at: datetime + predecessor_plan_id: uuid.UUID | None + revision: PlanRevisionResponse | None + missions: list[uuid.UUID] + mission_runs: dict[uuid.UUID, list[AcquisitionMissionRunResponse]] = Field(default_factory=dict) + + +class DiscoveryAssessmentCreate(BaseModel): + kind: str = Field( + pattern="^(support|counterevidence|unknown|coverage_gap|blocked|recommendation)$" + ) + statement: NonEmptyText + evidence_ids: list[uuid.UUID] = Field(default_factory=list) + assessment_ids: list[uuid.UUID] = Field(default_factory=list) + unknowns: list[NonEmptyText] = Field(default_factory=list) + coverage_gaps: list[NonEmptyText] = Field(default_factory=list) + recommendation: NonEmptyText | None = None + evidence_strength: Literal["unknown", "weak", "moderate", "strong"] = "unknown" + + +class DiscoveryAssessmentResponse(DiscoveryAssessmentCreate): + id: uuid.UUID + objective_id: uuid.UUID + version: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class NeedHypothesisCreate(BaseModel): + title: NonEmptyText = Field(max_length=255) + target_actor: NonEmptyText + context: NonEmptyText + problem: NonEmptyText + desired_outcome: NonEmptyText + workaround: NonEmptyText | None = None + unknowns: list[NonEmptyText] = Field(default_factory=list) + next_validation_action: NonEmptyText + support_assessment_ids: list[uuid.UUID] = Field(min_length=1) + + +class NeedHypothesisResponse(NeedHypothesisCreate): + id: uuid.UUID + objective_id: uuid.UUID + status: str + promoted_need_issue_id: uuid.UUID | None + created_at: datetime + model_config = {"from_attributes": True} + + +class NeedHypothesisPromotion(BaseModel): + operator: NonEmptyText + + +class DecisionRecordCreate(BaseModel): + decision: Literal["promoted", "rewritten", "abandoned", "blocked"] + reason: NonEmptyText + support_assessment_ids: list[uuid.UUID] = Field(default_factory=list) + counter_assessment_ids: list[uuid.UUID] = Field(default_factory=list) + unknowns: list[NonEmptyText] = Field(default_factory=list) + resource_usage: dict = Field(default_factory=dict) + + +class OutcomeFeedbackCreate(BaseModel): + kind: NonEmptyText + reference: NonEmptyText + summary: NonEmptyText + + +class OutcomeFeedbackResponse(OutcomeFeedbackCreate): + id: uuid.UUID + decision_record_id: uuid.UUID + created_at: datetime + + model_config = {"from_attributes": True} + + +class DiscoveryDecisionRecordResponse(DecisionRecordCreate): + id: uuid.UUID + objective_id: uuid.UUID + created_at: datetime + outcomes: list[OutcomeFeedbackResponse] = Field(default_factory=list) + + model_config = {"from_attributes": True} diff --git a/apps/api/schemas/external_signal.py b/apps/api/schemas/external_signal.py index b350569..fc3190d 100644 --- a/apps/api/schemas/external_signal.py +++ b/apps/api/schemas/external_signal.py @@ -8,6 +8,7 @@ class ExternalSignalCreate(BaseModel): + source_id: uuid.UUID | None = None source_label: str = Field(min_length=1) source_uri: str | None = None original_material: str = Field(min_length=1) @@ -33,6 +34,7 @@ class SignalTriageEventResponse(BaseModel): class ExternalSignalResponse(BaseModel): id: uuid.UUID mission_run_id: uuid.UUID | None + source_id: uuid.UUID | None mission_run_ids: list[uuid.UUID] = Field(default_factory=list) lineage_key: str | None raw_artifact_key: str | None diff --git a/apps/web/main.py b/apps/web/main.py index 8a11410..3ebc101 100644 --- a/apps/web/main.py +++ b/apps/web/main.py @@ -10,6 +10,7 @@ jobs, missions, needs, + objectives, observations, ontology, portfolio, @@ -22,6 +23,7 @@ web_app.include_router(experiments.router) web_app.include_router(web_sources.router) web_app.include_router(missions.router) +web_app.include_router(objectives.router) web_app.include_router(observations.router) web_app.include_router(agents.router) web_app.include_router(ontology.router) diff --git a/apps/web/routers/objectives.py b/apps/web/routers/objectives.py new file mode 100644 index 0000000..9d4d1a5 --- /dev/null +++ b/apps/web/routers/objectives.py @@ -0,0 +1,19 @@ +"""Web entry point for one Discovery Objective workspace.""" + +import uuid + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from ..templating import templates + +router = APIRouter(prefix="/objectives", tags=["Web Discovery Objectives"]) + + +@router.get("/{objective_id}", response_class=HTMLResponse) +async def discovery_objective_workspace(objective_id: uuid.UUID, request: Request): + return templates.TemplateResponse( + request, + "objectives/workspace.html", + {"title": "Discovery Objective", "objective_id": objective_id}, + ) diff --git a/apps/web/static/style.css b/apps/web/static/style.css index 1ef0e0c..04abfe7 100644 --- a/apps/web/static/style.css +++ b/apps/web/static/style.css @@ -1,49 +1,23 @@ -/* SourceOS — minimal custom styles on top of Pico.css */ - -.status-active { color: var(--pico-color-green-400); font-weight: bold; } -.status-paused { color: var(--pico-color-amber-400); } -.status-error { color: var(--pico-color-red-400); font-weight: bold; } -.status-discovered { color: var(--pico-color-blue-400); } -.status-fetched { color: var(--pico-color-green-400); } -.status-failed { color: var(--pico-color-red-400); } -.status-success { color: var(--pico-color-green-400); } -.status-queued { color: var(--pico-color-grey-400); } -.status-running { color: var(--pico-color-amber-400); } - -.badge { - display: inline-block; - padding: 0.1rem 0.5rem; - background: var(--pico-color-grey-100); - border-radius: 4px; - font-size: 0.8rem; - font-weight: 500; -} - -.error-code { - font-family: monospace; - font-size: 0.85rem; - background: var(--pico-color-red-50); - padding: 0.1rem 0.4rem; - border-radius: 3px; -} - -.markdown-body { - max-width: 800px; - line-height: 1.7; -} -.markdown-body img { max-width: 100%; } -.markdown-body pre { overflow-x: auto; } - -.workbench-heading, .section-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; } -.workbench-heading > div, .section-heading > div { max-width: 48rem; } -.workbench-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1rem; } -.eyebrow { margin-bottom: .25rem; color: var(--pico-primary); font-size: .75rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } -.config-status { margin: 1rem 0 0; font-size: .9rem; } -.execution-warning { align-self: end; padding: .85rem 1rem; border-left: 3px solid var(--pico-color-amber-400); background: var(--pico-card-background-color); } -.message-error { border-left: 4px solid var(--pico-color-red-400); } -.message-success { border-left: 4px solid var(--pico-color-green-400); } -.run-metrics { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; margin: 1rem 0; } -.run-metrics span { font-size: .9rem; } -.failure-detail { color: var(--pico-color-red-400); } -.evidence-list { display: grid; gap: .75rem; } -@media (max-width: 600px) { .workbench-heading, .section-heading { flex-direction: column; } } +/* SourceOS control room — operator-facing collection workspace */ +:root { --ink: #20251f; --ink-2: #313830; --canvas: #d9d7cf; --paper: #f5f4ef; --panel: #fffefa; --line: #d8d6ce; --muted: #777b73; --green: #215442; --green-soft: #dceae1; --amber: #c49439; --red: #a94432; --red-soft: #f6e5dd; --shadow: 0 12px 24px rgba(30, 37, 31, .06); --pico-font-family: "Avenir Next", "PingFang SC", "Noto Sans SC", sans-serif; --pico-border-radius: 14px; --pico-primary: var(--green); --pico-primary-background: var(--green); --pico-primary-border: var(--green); --pico-form-element-border-color: var(--line); --pico-form-element-background-color: #fffefa; --pico-card-background-color: var(--panel); } +* { box-sizing: border-box; } +body { margin: 0; background: var(--canvas); color: var(--ink); font-size: 14px; } +.app-shell { display: grid; grid-template-columns: 222px minmax(0, 1fr); grid-template-rows: 58px minmax(0, 1fr); height: min(920px, 94vh); max-width: 1500px; margin: 24px auto; overflow: hidden; border: 1px solid #c7c4bb; border-radius: 16px; background: var(--paper); box-shadow: 0 28px 80px rgba(32,35,31,.22); } +.app-sidebar { grid-column: 1; grid-row: 1 / -1; display: flex; flex-direction: column; padding: 0 11px 18px; background: #242722; color: #edf0ea; } +.brand-lockup { display: flex; align-items: center; gap: 11px; min-height: 58px; margin: 0 -11px 15px; padding: 0 19px; border-bottom: 1px solid #3b3e38; color: #f8f8f4; text-decoration: none; font-size: 14px; } +.brand-lockup small { margin-left: auto; color: #aeb3aa; font-size: 10px; font-weight: 500; }.brand-mark { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid #899187; border-radius: 7px; font-size: 11px; font-weight: 800; letter-spacing: -1px; } +.primary-nav, .secondary-nav { display: grid; gap: 4px; } +.app-sidebar nav a { display: flex; align-items: center; gap: 10px; min-height: 37px; padding: 0 10px; border-radius: 7px; color: #c6cbc2; font-size: 13px; font-weight: 600; text-decoration: none; }.app-sidebar nav a span { color: #9da49b; font-size: 16px; line-height: 1; }.app-sidebar nav a b { min-width: 20px; margin-left: auto; padding: 1px 6px; border-radius: 9px; background: #3b4039; color: #dfe3dc; font-size: 10px; text-align: center; } +.app-sidebar nav a.active, .app-sidebar nav a:hover { background: #f2f5ef; color: #213128; }.app-sidebar nav a.active span { color: var(--green); }.app-sidebar nav a.active b { background: #d5e7dc; color: var(--green); } +.nav-label { margin: 13px 10px 7px; color: #83897f; font-size: 10px; letter-spacing: .12em; }.weekly-budget { margin: auto 9px 0; padding-top: 13px; border-top: 1px solid #3a4039; color: #eef0ec; }.weekly-budget strong { font-size: 11px; }.weekly-budget small { color: #9da49b; font-size: 10px; }.budget-track { height: 4px; margin: 8px 0 5px; overflow: hidden; border-radius: 999px; background: #4b544a; }.budget-track i { display: block; width: 41%; height: 100%; border-radius: inherit; background: #8fab97; } +.app-frame { grid-column: 2; grid-row: 1 / -1; min-width: 0; display: grid; grid-template-rows: 58px minmax(0, 1fr); }.topbar { display: flex; align-items: center; justify-content: space-between; min-height: 58px; padding: 0 20px; border-bottom: 1px solid var(--line); background: #fbfaf6; }.topbar p { margin: 0; color: var(--muted); font-size: 13px; }.topbar strong { color: var(--ink); }.topbar-actions { display: flex; align-items: center; gap: 14px; color: var(--muted); }.command-search { display: flex; align-items: center; width: 260px; margin: 0; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; background: #fff; }.command-search span { margin-right: 10px; color: #92978f; white-space: nowrap; }.command-search input { height: 35px; margin: 0; padding: 0; border: 0; box-shadow: none; background: transparent; font-size: 13px; }.command-search input:focus { box-shadow: none; }.operator-avatar { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 50%; background: var(--green); color: #fff; font-size: 11px; } +.app-content { padding: 18px 20px 20px; overflow: auto; }.collection-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 15px; }.collection-heading h1 { margin: 0 0 3px; font-family: Georgia, "Songti SC", serif; font-size: 24px; letter-spacing: -.02em; }.collection-heading p:not(.eyebrow) { margin: 0; color: var(--muted); font-size: 13px; }.eyebrow { display: none; }.head-actions { display:flex; gap:8px; }.collection-heading a[role=button], .head-actions button { min-height: 35px; margin: 0; padding: 8px 12px; } +article, .collection-panel > section { margin: 0; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); box-shadow: var(--shadow); }article > header { display: flex; justify-content: space-between; align-items: center; min-height: 66px; padding: 0 18px; border-bottom: 1px solid var(--line); font-size: 17px; }article > header small { color: var(--muted); font-size: 13px; font-weight: 500; }.cold-water-notice { display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center; margin-bottom: 20px; padding: 15px 20px; border-color: #e3b9aa; background: var(--red-soft); box-shadow: none; color: #4c423e; }.cold-water-notice strong { color: var(--red); }.cold-water-notice a { color: var(--red); font-weight: 750; text-decoration: none; white-space: nowrap; } +#collection-layout { display: grid; grid-template-columns: 280px minmax(430px, 1fr) 320px; gap: 18px; align-items: start; }.collection-panel { display: grid; gap: 18px; min-width: 0; }.collection-panel article { padding-bottom: 18px; }.collection-panel article > :not(header) { margin-right: 18px; margin-left: 18px; }.collection-panel h2 { margin-top: 17px; font-size: 19px; }.collection-panel p { line-height: 1.6; }.source-library-link { width: calc(100% - 36px); margin-top: 20px !important; } +label { color: #62675f; font-size: 13px; font-weight: 700; } input, textarea, select { margin-top: 7px; margin-bottom: 14px; border-color: var(--line); border-radius: 10px; box-shadow: none; font-size: 15px; } textarea { line-height: 1.48; } button, [role=button] { min-height: 42px; border-radius: 10px; font-size: 14px; font-weight: 750; } button:active, [role=button]:active { transform: translateY(1px); } +.app-content button:not(.secondary):not(.outline), .app-content [role=button]:not(.secondary):not(.outline) { background: var(--green); border-color: var(--green); color: #fff; } .app-content button.secondary, .app-content [role=button].secondary { background: #e9ece7; border-color: var(--line); color: #334038; } .app-content button.outline, .app-content [role=button].outline, .app-content button.secondary.outline, .app-content [role=button].secondary.outline { background: transparent; border-color: #bfc5ba; color: #334038; } +#mission-editor .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }#mission-editor .grid:has(#request-limit) { grid-template-columns: repeat(3, minmax(0, 1fr)); }#mission-editor .grid:has(#time-budget) { grid-template-columns: repeat(3, minmax(0, 1fr)); }.config-status { padding: 11px 13px; border-radius: 9px; background: #eff2ed; color: #526256; font-size: 13px; }.execution-warning { padding: 13px; border-left: 3px solid var(--amber); border-radius: 8px; background: #f7f1df; }.triage-actions { display: flex; flex-wrap: wrap; gap: 8px; }.run-metrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }.run-metrics span { padding: 9px; border-radius: 8px; background: #f1f0eb; color: var(--muted); font-size: 12px; }.run-metrics strong { display: block; color: var(--ink); font-size: 15px; }.failure-detail { color: var(--red); }.message-error { margin-bottom: 14px; padding: 10px 14px; border-left: 4px solid var(--red); }.message-success { margin-bottom: 14px; padding: 10px 14px; border-left: 4px solid var(--green); }.evidence-list { display: grid; gap: 8px; }.evidence-list article { box-shadow: none; }.evidence-list article > header { min-height: 46px; font-size: 14px; }.rail-history, .rail-inbox { border: 0 !important; background: transparent !important; box-shadow: none !important; }.rail-history > .section-heading, .rail-inbox > .section-heading { padding: 0 0 10px; }.rail-history > p, .rail-inbox > p { color: var(--muted); font-size: 12px; }.rail-history .evidence-list article, .rail-inbox .evidence-list article { padding-bottom: 12px; }.badge { display: inline-block; padding: 3px 7px; border-radius: 999px; background: var(--green-soft); color: var(--green); font-size: 11px; font-weight: 800; }.error-code { font-family: monospace; background: var(--red-soft); padding: 2px 5px; border-radius: 3px; }.markdown-body { max-width: 800px; line-height: 1.7; }.markdown-body img { max-width: 100%; }.markdown-body pre { overflow-x: auto; } +.collection-tabs { display:flex; gap:2px; margin-bottom:15px; border-bottom:1px solid var(--line); }.collection-tabs a, .collection-tabs button { min-height:0; padding:9px 12px 10px; border:0; border-bottom:2px solid transparent; border-radius:0; background:transparent !important; color:var(--muted) !important; font-size:13px; font-weight:500; text-decoration:none; }.collection-tabs .active { color:var(--green) !important; border-bottom-color:var(--green); font-weight:700; }.collection-tabs small { margin-left:4px; padding:1px 5px; border-radius:10px; background:#e3e1da; font-size:10px; }.cold-water-notice { margin-bottom:15px; padding:12px 14px; border-left:4px solid var(--red); border-radius:9px; }.cold-water-notice strong { font-size:11px; letter-spacing:.08em; }.cold-water-notice span { font-size:13px; }.cold-water-notice a { font-size:11px; } +#collection-layout { grid-template-columns:minmax(250px,.72fr) minmax(460px,1.5fr) minmax(255px,.78fr); gap:12px; }.collection-panel { gap:12px; }.collection-panel article { padding-bottom:12px; }.collection-panel article > :not(header) { margin-right:12px; margin-left:12px; }.collection-panel h2 { margin-top:12px; font-size:15px; }.collection-panel p { line-height:1.45; }.source-library-link { width:calc(100% - 24px); margin-top:8px !important; } +@media (max-width: 1280px) { .app-shell { margin: 0; max-width: none; border-radius: 0; }.app-sidebar { width: 248px; }.app-shell { grid-template-columns: 248px minmax(0, 1fr); } #collection-layout { grid-template-columns: 230px minmax(360px, 1fr); }.operation-rail { grid-column: 1 / -1; grid-template-columns: repeat(3, 1fr); display: grid; }.command-search { width: 260px; } } +@media (max-width: 820px) { .app-shell { display: block; }.app-sidebar { display: none; }.topbar { padding: 0 16px; }.topbar-actions small, .command-search { display: none; }.app-content { padding: 20px 16px; }.collection-heading { align-items: flex-start; flex-direction: column; }.cold-water-notice { grid-template-columns: 1fr; gap: 7px; }.cold-water-notice a { white-space: normal; } #collection-layout, .operation-rail { display: grid; grid-template-columns: 1fr; }.collection-panel { gap: 14px; } #mission-editor .grid, #mission-editor .grid:has(#request-limit), #mission-editor .grid:has(#time-budget) { grid-template-columns: 1fr; } } diff --git a/apps/web/templates/base.html b/apps/web/templates/base.html index 7218493..580b48f 100644 --- a/apps/web/templates/base.html +++ b/apps/web/templates/base.html @@ -5,39 +5,36 @@ {% block title %}SourceOS{% endblock %} — 现实需求发现工作台 - + -
- -
- -
- {% block content %}{% endblock %} -
- - +
+ +
+
+

{% block breadcrumb %}现实采集 / 采集控制台{% endblock %}

+
本周剩余 4h 20m
+
+
{% block content %}{% endblock %}
+
+
diff --git a/apps/web/templates/dashboard.html b/apps/web/templates/dashboard.html index 6e73736..92417b4 100644 --- a/apps/web/templates/dashboard.html +++ b/apps/web/templates/dashboard.html @@ -1,51 +1,57 @@ {% extends "base.html" %} -{% block title %}Dashboard{% endblock %} +{% block title %}采集工作台{% endblock %} {% block content %} -

Personal evidence operations

-

Reality Workbench

-

先处理现实观察、最强异议和最小下一步;来源、条目和运行数量只在需要排障时展开,不能替代需求判断。

+

需求挖掘 · 最小可用闭环

+

采集工作台

+

从定义信源开始,采集公开内容,保留原文上下文,再把值得研究的内容送入需求分析。这里不替你联系任何人,也不把评论当作已被验证的需求。

-
-
Today: reality-facing work
-

Loading the current evidence, objection, and smallest next action…

-
+
+
+
1. 定义信源
+

添加一个你要持续关注的平台、社区或内容入口,并配置采集频率与边界。

+ Define a source +
+
+
2. 配置并执行采集
+

为选定信源固定采集配置,先预览,再由你决定是否执行有限范围的公开采集。

+ Open collection workbench +
+
+
3. 审阅证据候选
+

只有带原文与上下文的内容才能进入需求判断;接受、忽略或标记待查都保留理由。

+ Review collected evidence +
+
-

Start from reality

-
- Record an observation - Plan a bounded mission - Review sources and configuration -
+
+
最近采集到的证据候选
+

Loading…

+
-
What this workbench does not claim
-

A collected comment, an Agent proposal, an ontology relationship, or a finished Feature is not proof of demand, payment, retention, or profit. Each must remain traceable to observations, counterevidence, and a next reality action.

+
这一步的完成标准
+

你能独立完成一次:创建信源 → 固定配置 → 预览或执行采集 → 查看原始上下文 → 做出证据处置。需求假设与产品定义在此之后再进入下一步。

{% endblock %} diff --git a/apps/web/templates/missions/workbench.html b/apps/web/templates/missions/workbench.html index 4b88a82..65be934 100644 --- a/apps/web/templates/missions/workbench.html +++ b/apps/web/templates/missions/workbench.html @@ -2,86 +2,97 @@ {% block title %}Mission Workbench{% endblock %} {% block content %} -
+
-

Evidence acquisition

-

Mission Workbench

-

把一个现实问题约束成可复核的采集任务。这里产出的是证据候选,不是已经成立的需求。

+

现实采集 / 采集控制台

+

现实采集工作台

+

管理“为什么采、从哪里采、采到什么程度”,而不是启动一个黑箱爬虫。

- Create source +
导入材料
-
+ + +
冷水提醒采集量不是证据强度。重复的同源评论不会自动变成独立需求。检查信源组合 →
+ +
+ +
-
2. Pin a collection configuration
-

A mission always records an immutable configuration version. Adjusting this form creates a new version; it never rewrites an old run.

+
任务定义草稿 · 自动保存
+

固定采集配置

+

每次采集都固定一版配置。修改会创建新版本,不会改写既有运行记录。

-
-
-
+ -
-
3. Define a bounded reality question
+
+
本次采集要回答的问题
-
-
+