feat(contextual): scope schema, render_scope_block, and schemas package refactor
Convert app/schemas.py → app/schemas/__init__.py so the contextual module can live at app/schemas/contextual.py while keeping all existing 'from app.schemas import ...' calls unchanged. ContextualScope mirrors the renderer's camelCase payload via alias_generator=to_camel. render_scope_block produces a single-paragraph human-readable summary injected into the contextual agent system prompt. 4 tests, all passing.
This commit is contained in:
73
app/schemas/contextual.py
Normal file
73
app/schemas/contextual.py
Normal file
@@ -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}."
|
||||||
52
tests/test_contextual_scope.py
Normal file
52
tests/test_contextual_scope.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user