diff --git a/app/schemas.py b/app/schemas/__init__.py similarity index 100% rename from app/schemas.py rename to app/schemas/__init__.py diff --git a/app/schemas/contextual.py b/app/schemas/contextual.py new file mode 100644 index 0000000..b995168 --- /dev/null +++ b/app/schemas/contextual.py @@ -0,0 +1,73 @@ +"""Contextual sidebar scope schema and prompt block renderer. + +ContextualScope mirrors the TypeScript ContextualScope type sent by the +Electron renderer when the user opens the side chat anchored to a specific +view. The renderer ships camelCase keys; Pydantic's alias_generator maps +them to snake_case Python attributes automatically. +""" + +from __future__ import annotations + +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +PageType = Literal[ + "timeline", + "tasks", + "projects-list", + "project", + "note", +] + +EntityType = Literal["project", "note", "task", "timeline_event"] + + +class ContextualScope(BaseModel): + """Scope payload sent by the Electron renderer for contextual chat. + + The renderer ships camelCase keys (entityType, entityId, ...). Pydantic's + alias generator maps them to snake_case Python attrs. + """ + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + page: PageType + entity_type: Optional[EntityType] = None + entity_id: Optional[str] = None + entity_name: Optional[str] = None + project_id: Optional[str] = None + char_count: Optional[int] = None + counts: Optional[dict[str, int]] = None + filters: Optional[dict] = None + + +def render_scope_block(scope: ContextualScope) -> str: + """Produce a single-paragraph human-readable summary of the current view + for injection into the contextual agent system prompt. + + Never emits internal ids — only names. The LLM is told to use names in + prose; ids travel through tool calls. + """ + if scope.entity_type == "project": + c = scope.counts or {} + return ( + f"User is viewing the project {scope.entity_name!r}. " + f"{c.get('tasks', 0)} tasks, " + f"{c.get('notes', 0)} notes, " + f"{c.get('milestones', 0)} milestones." + ) + if scope.entity_type == "note": + return ( + f"User is viewing the note {scope.entity_name!r} " + f"({scope.char_count or 0} characters)." + ) + if scope.page == "tasks": + return "User is viewing the global Tasks list (all projects)." + if scope.page == "timeline": + return "User is viewing the global Timeline view." + if scope.page == "projects-list": + return "User is viewing the Projects list." + return f"User is on page {scope.page}." diff --git a/tests/test_contextual_scope.py b/tests/test_contextual_scope.py new file mode 100644 index 0000000..ba25b31 --- /dev/null +++ b/tests/test_contextual_scope.py @@ -0,0 +1,52 @@ +import pytest +from app.schemas.contextual import ContextualScope, render_scope_block + + +def test_render_project_scope(): + scope = ContextualScope( + page="project", + entity_type="project", + entity_id="p1", + entity_name="Acme Q3 launch", + counts={"tasks": 12, "notes": 4, "milestones": 3}, + ) + block = render_scope_block(scope) + assert "Acme Q3 launch" in block + assert "12 tasks" in block + assert "4 notes" in block + assert "3 milestones" in block + assert "p1" not in block + + +def test_render_list_scope_no_entity(): + scope = ContextualScope(page="tasks", entity_type=None) + block = render_scope_block(scope) + assert "tasks" in block.lower() + assert "None" not in block + + +def test_render_note_scope_includes_char_count(): + scope = ContextualScope( + page="note", + entity_type="note", + entity_id="n1", + entity_name="Meeting 14 May", + project_id="p1", + char_count=4280, + ) + block = render_scope_block(scope) + assert "Meeting 14 May" in block + assert "4280" in block or "4,280" in block + + +def test_parses_camelcase_payload_from_renderer(): + payload = { + "page": "project", + "entityType": "project", + "entityId": "p1", + "entityName": "Acme", + "counts": {"tasks": 5, "notes": 1, "milestones": 2}, + } + scope = ContextualScope.model_validate(payload) + assert scope.entity_id == "p1" + assert scope.entity_name == "Acme"