mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 05:00:37 +00:00
Phase 5: iteration & polish (visualizations, goal history, smart tagging)
Final roadmap phase. No DB migration — it reads data already captured.
- Goal-evolution history: GET /discovery/profile/me/goal-history derives a
per-goal timeline from the profile_revision snapshots (pure aggregator in
app/services/profile_history.py).
- Smart tagging: POST /discovery/integration/suggest-foundation suggests which
foundation a task builds toward + rationale/confidence (FoundationTagger,
app/services/tagging.py). Suggestion only; the person confirms by posting the
task mapping.
- Deeper goal-refinement: the reflect loop accepts an optional focus ("goals")
that steers the coach toward sharpening goals — still mirror, not compass.
- Visualizations: visuals.html renders an Ikigai Venn and an Enneagram diagram
(plain-language callouts, not the raw type number) plus the goal-evolution
timeline; linked from profile.html.
Tests: 99 passing (added pure goal-history tests, goal-history + suggest
endpoint tests, reflect-focus passthrough; run in-container). README updated.
This completes the ImpactFlow Vision roadmap (Phases 1-5) on the Discovery side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,10 @@ If you need to explain this app in detail, use this mental model:
|
|||||||
profile foundation via `POST /discovery/integration/task-mappings`. The
|
profile foundation via `POST /discovery/integration/task-mappings`. The
|
||||||
rolled-up work patterns drive `/static/dashboard.html` and are fed into the
|
rolled-up work patterns drive `/static/dashboard.html` and are fed into the
|
||||||
coaching check-ins so they can reflect where time has actually gone.
|
coaching check-ins so they can reflect where time has actually gone.
|
||||||
|
15. (Phase 5) `/static/visuals.html` shows the Ikigai/Enneagram visuals and the
|
||||||
|
goal-evolution timeline; the tracker can call `suggest-foundation` for
|
||||||
|
smart tagging, and the reflect loop accepts a `focus` for deeper
|
||||||
|
goal-refinement.
|
||||||
|
|
||||||
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
|
Machine-to-machine callers (e.g. the MCP server) skip the OAuth dance and
|
||||||
authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header
|
authenticate with `X-API-Key: $IMPACTFLOW_API_KEY` instead. That header
|
||||||
@@ -152,13 +156,16 @@ Important files:
|
|||||||
| `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) |
|
| `app/static/reflect.html` | Phase 2 AI-coach reflection chat (mirror loop, applies revisions, affirm) |
|
||||||
| `app/static/coaching.html` | Phase 3 coaching preferences form + check-in feed |
|
| `app/static/coaching.html` | Phase 3 coaching preferences form + check-in feed |
|
||||||
| `app/static/dashboard.html` | Phase 4 goal dashboard: time per foundation + neglected ones |
|
| `app/static/dashboard.html` | Phase 4 goal dashboard: time per foundation + neglected ones |
|
||||||
|
| `app/static/visuals.html` | Phase 5 visualizations: Ikigai Venn, Enneagram diagram, goal-evolution timeline |
|
||||||
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
|
| `app/static/auth.js` | Shared `authedFetch` helper: sends session cookies, silently refreshes on `401`, redirects to login |
|
||||||
| `app/static/style.css` | Shared UI styling |
|
| `app/static/style.css` | Shared UI styling |
|
||||||
| `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering |
|
| `app/services/reflector.py` | `ReflectionCoach`: Anthropic-backed mirror loop, JSON parsing, revision filtering |
|
||||||
| `app/services/coaching.py` | Deterministic preference generator + `CheckinCoach` (Anthropic check-in text) |
|
| `app/services/coaching.py` | Deterministic preference generator + `CheckinCoach` (Anthropic check-in text) |
|
||||||
| `app/services/foundations.py` | The six foundations + pure work-pattern aggregator (Phase 4) |
|
| `app/services/foundations.py` | The six foundations + pure work-pattern aggregator (Phase 4) |
|
||||||
|
| `app/services/profile_history.py` | Pure goal-evolution aggregator over profile revisions (Phase 5) |
|
||||||
|
| `app/services/tagging.py` | `FoundationTagger`: Anthropic smart-tagging suggestion (Phase 5) |
|
||||||
| `app/routers/coaching.py` | Phase 3 coaching routes: preferences, check-ins, weekly batch `/run` |
|
| `app/routers/coaching.py` | Phase 3 coaching routes: preferences, check-ins, weekly batch `/run` |
|
||||||
| `app/routers/integration.py` | Phase 4 task-to-goal integration: foundations, task-mappings, work-patterns |
|
| `app/routers/integration.py` | Phase 4/5 integration: foundations, task-mappings, work-patterns, suggest-foundation |
|
||||||
| `alembic/versions/001_initial.py` | Initial database schema migration |
|
| `alembic/versions/001_initial.py` | Initial database schema migration |
|
||||||
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
| `alembic/versions/002_add_auth.py` | Adds `users`, `refresh_tokens`, and `activity_log` tables |
|
||||||
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
|
| `alembic/versions/003_add_goals.py` | Adds the goal columns to `discovery_conversation` and `discovery_profile` |
|
||||||
@@ -172,6 +179,8 @@ Important files:
|
|||||||
| `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch, work-pattern wiring) |
|
| `tests/test_coaching.py` | Tests for coaching endpoints (preferences, check-ins, due-logic batch, work-pattern wiring) |
|
||||||
| `tests/test_foundations.py` | Unit tests for the pure work-pattern aggregator |
|
| `tests/test_foundations.py` | Unit tests for the pure work-pattern aggregator |
|
||||||
| `tests/test_integration.py` | Tests for the task-to-goal integration endpoints |
|
| `tests/test_integration.py` | Tests for the task-to-goal integration endpoints |
|
||||||
|
| `tests/test_profile_history.py` | Unit tests for the pure goal-evolution aggregator |
|
||||||
|
| `tests/test_phase5.py` | Tests for goal-history and smart-tagging endpoints |
|
||||||
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
|
| `tests/test_auth.py` | Tests for the dual-auth dependency (JWT + cookie + API key), token refresh/logout, admin enforcement, and domain allow-list |
|
||||||
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
|
| `tests/test_profile_edit.py` | Tests for `PATCH /discovery/profile/me` (edit, partial update, lock/`409`) |
|
||||||
| `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) |
|
| `tests/test_reflection.py` | Tests for the reflection endpoints (turns, applied revisions, lock/`409`, history) |
|
||||||
@@ -461,6 +470,23 @@ the user (forwarded session/JWT) or service-to-service with `X-API-Key`.
|
|||||||
time has and hasn't gone — as an observation to check against the person's
|
time has and hasn't gone — as an observation to check against the person's
|
||||||
own words, never a verdict (mirror, not compass).
|
own words, never a verdict (mirror, not compass).
|
||||||
|
|
||||||
|
### 11. Iteration & Polish (Phase 5)
|
||||||
|
|
||||||
|
- **Goal-evolution history:** `GET /discovery/profile/me/goal-history` derives a
|
||||||
|
per-goal timeline (near- and long-term) from the `profile_revision` snapshots
|
||||||
|
captured since Phase 2 — one entry per actual change, with `source` and `at`.
|
||||||
|
No new storage; it reads existing revisions.
|
||||||
|
- **Smart tagging:** `POST /discovery/integration/suggest-foundation`
|
||||||
|
(`{task_label}`) returns a suggested `foundation` + `rationale` + `confidence`.
|
||||||
|
It only suggests — the person confirms by posting the task mapping. The core
|
||||||
|
tracker uses this to pre-fill "which goal does this build toward?".
|
||||||
|
- **Deeper goal-refinement:** the reflect loop (Phase 2) accepts an optional
|
||||||
|
`focus` (e.g. `"goals"`) that steers the coach toward sharpening the
|
||||||
|
near/long-term goals — still a mirror.
|
||||||
|
- **Visualizations:** `visuals.html` renders an Ikigai four-circle Venn and an
|
||||||
|
Enneagram diagram from the profile (plain-language callouts — it does not
|
||||||
|
headline the raw type number), plus the goal-evolution timeline.
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
All `/discovery/*`, `/api/me*`, `/api/activity*`, and `/api/admin/*` routes
|
||||||
@@ -507,6 +533,8 @@ clients.) `/api/auth/login`, `/api/auth/callback`, `/health`, `/`, and
|
|||||||
| `POST` | `/discovery/integration/task-mappings` | yes | Record a logged unit of work mapped to a foundation |
|
| `POST` | `/discovery/integration/task-mappings` | yes | Record a logged unit of work mapped to a foundation |
|
||||||
| `GET` | `/discovery/integration/task-mappings` | yes | List the user's task mappings in a window |
|
| `GET` | `/discovery/integration/task-mappings` | yes | List the user's task mappings in a window |
|
||||||
| `GET` | `/discovery/integration/work-patterns` | yes | Per-foundation work-pattern rollup over a window |
|
| `GET` | `/discovery/integration/work-patterns` | yes | Per-foundation work-pattern rollup over a window |
|
||||||
|
| `POST` | `/discovery/integration/suggest-foundation` | yes | Smart tagging: suggest a foundation for a task (person confirms) |
|
||||||
|
| `GET` | `/discovery/profile/me/goal-history` | yes | Timeline of how the person's goals evolved |
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
@@ -780,6 +808,14 @@ callback), so the pages hold no tokens of their own.
|
|||||||
the person wants their energy — it observes, it does not prescribe
|
the person wants their energy — it observes, it does not prescribe
|
||||||
- linked from `profile.html` ("Where your time goes")
|
- linked from `profile.html` ("Where your time goes")
|
||||||
|
|
||||||
|
`visuals.html` (Phase 5 visualizations):
|
||||||
|
|
||||||
|
- reads `GET /discovery/profile/me` and renders an Ikigai four-circle Venn and
|
||||||
|
an Enneagram diagram in SVG (callouts stay in plain language)
|
||||||
|
- reads `GET /discovery/profile/me/goal-history` and shows how the near- and
|
||||||
|
long-term goals have changed over time
|
||||||
|
- linked from `profile.html` ("See your profile visualized")
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Populate `.env` with at minimum the Anthropic key and the auth-related
|
Populate `.env` with at minimum the Anthropic key and the auth-related
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.services.reflector import (
|
|||||||
ReflectionCoach,
|
ReflectionCoach,
|
||||||
ReflectionError,
|
ReflectionError,
|
||||||
)
|
)
|
||||||
|
from app.services.profile_history import goal_history
|
||||||
|
|
||||||
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
router = APIRouter(prefix="/discovery", tags=["discovery"])
|
||||||
|
|
||||||
@@ -383,7 +384,11 @@ async def reflect_on_profile(
|
|||||||
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||||
try:
|
try:
|
||||||
coach = ReflectionCoach(api_key=api_key, model=model)
|
coach = ReflectionCoach(api_key=api_key, model=model)
|
||||||
result = await coach.reflect(_profile_fields(profile) | {"triad": profile.triad}, coach_history)
|
result = await coach.reflect(
|
||||||
|
_profile_fields(profile) | {"triad": profile.triad},
|
||||||
|
coach_history,
|
||||||
|
focus=payload.focus,
|
||||||
|
)
|
||||||
except ReflectionError as exc:
|
except ReflectionError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -459,6 +464,44 @@ async def get_profile_revisions(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/profile/me/goal-history", response_model=schemas.GoalHistoryOut
|
||||||
|
)
|
||||||
|
async def get_goal_history(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""How the person's near/long-term goals evolved over time, derived from
|
||||||
|
the profile revision snapshots (Phase 5)."""
|
||||||
|
profile = await _latest_profile(db, user.id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||||
|
rev_stmt = (
|
||||||
|
select(ProfileRevision)
|
||||||
|
.where(ProfileRevision.profile_id == profile.id)
|
||||||
|
.order_by(ProfileRevision.created_at)
|
||||||
|
)
|
||||||
|
rows = (await db.execute(rev_stmt)).scalars().all()
|
||||||
|
revisions = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
fields = json.loads(r.fields_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
fields = {}
|
||||||
|
revisions.append(
|
||||||
|
{"fields": fields, "source": r.source, "created_at": r.created_at}
|
||||||
|
)
|
||||||
|
timelines = goal_history(revisions)
|
||||||
|
return schemas.GoalHistoryOut(
|
||||||
|
short_term_goals=[
|
||||||
|
schemas.GoalHistoryEntry(**e) for e in timelines["short_term_goals"]
|
||||||
|
],
|
||||||
|
long_term_goals=[
|
||||||
|
schemas.GoalHistoryEntry(**e) for e in timelines["long_term_goals"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/conversation/{conversation_id}",
|
"/conversation/{conversation_id}",
|
||||||
response_model=schemas.ConversationResponse,
|
response_model=schemas.ConversationResponse,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ up to feed the coaching reminder engine and the goal dashboard.
|
|||||||
All routes are user-scoped via the dual-auth dependency; the tracker calls as
|
All routes are user-scoped via the dual-auth dependency; the tracker calls as
|
||||||
the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``.
|
the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ from app.services.foundations import (
|
|||||||
FOUNDATIONS,
|
FOUNDATIONS,
|
||||||
rollup,
|
rollup,
|
||||||
)
|
)
|
||||||
|
from app.services.tagging import FoundationTagger, TaggingError
|
||||||
|
|
||||||
router = APIRouter(prefix="/discovery/integration", tags=["integration"])
|
router = APIRouter(prefix="/discovery/integration", tags=["integration"])
|
||||||
|
|
||||||
@@ -141,6 +143,44 @@ async def work_patterns_for(
|
|||||||
return rollup(entries)
|
return rollup(entries)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/suggest-foundation", response_model=schemas.SuggestFoundationOut
|
||||||
|
)
|
||||||
|
async def suggest_foundation(
|
||||||
|
payload: schemas.SuggestFoundationRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Phase 5 smart tagging: suggest which foundation a task builds toward.
|
||||||
|
The person confirms by posting the task mapping. Suggestion only — nothing
|
||||||
|
is stored here."""
|
||||||
|
label = payload.task_label.strip()
|
||||||
|
if not label:
|
||||||
|
raise HTTPException(status_code=400, detail="task_label is required")
|
||||||
|
profile = await _latest_profile(db, user.id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No profile for this user")
|
||||||
|
|
||||||
|
foundations_text = {
|
||||||
|
key: getattr(profile, field, None)
|
||||||
|
for key, field in FOUNDATION_TO_FIELD.items()
|
||||||
|
}
|
||||||
|
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
|
||||||
|
try:
|
||||||
|
tagger = FoundationTagger(api_key=api_key, model=model)
|
||||||
|
result = await tagger.suggest(label, foundations_text)
|
||||||
|
except TaggingError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return schemas.SuggestFoundationOut(
|
||||||
|
foundation=result["foundation"],
|
||||||
|
label=FOUNDATIONS[result["foundation"]],
|
||||||
|
rationale=result["rationale"],
|
||||||
|
confidence=result["confidence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/work-patterns", response_model=schemas.WorkPatternsOut)
|
@router.get("/work-patterns", response_model=schemas.WorkPatternsOut)
|
||||||
async def get_work_patterns(
|
async def get_work_patterns(
|
||||||
days: int = Query(30, ge=1, le=365),
|
days: int = Query(30, ge=1, le=365),
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ class ConfirmResponse(BaseModel):
|
|||||||
class ReflectRequest(BaseModel):
|
class ReflectRequest(BaseModel):
|
||||||
# Empty/omitted starts the loop (the coach's opening reflection).
|
# Empty/omitted starts the loop (the coach's opening reflection).
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
# Phase 5: optional refinement focus, e.g. "goals" to steer the coach
|
||||||
|
# toward sharpening the near/long-term goals.
|
||||||
|
focus: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ReflectionMessageOut(BaseModel):
|
class ReflectionMessageOut(BaseModel):
|
||||||
@@ -194,6 +197,31 @@ class WorkPatternsOut(BaseModel):
|
|||||||
neglected: list[str]
|
neglected: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Phase 5: iteration & polish ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class GoalHistoryEntry(BaseModel):
|
||||||
|
value: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GoalHistoryOut(BaseModel):
|
||||||
|
short_term_goals: list[GoalHistoryEntry]
|
||||||
|
long_term_goals: list[GoalHistoryEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestFoundationRequest(BaseModel):
|
||||||
|
task_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestFoundationOut(BaseModel):
|
||||||
|
foundation: str
|
||||||
|
label: str
|
||||||
|
rationale: str = ""
|
||||||
|
confidence: str = "low"
|
||||||
|
|
||||||
|
|
||||||
class ConversationResponse(BaseModel):
|
class ConversationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Phase 5: derive how a person's goals evolved from the profile_revision
|
||||||
|
snapshots already captured since Phase 2.
|
||||||
|
|
||||||
|
Pure functions over revision rows — no DB access, fully testable.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
# The goal fields we track an evolution timeline for.
|
||||||
|
GOAL_FIELDS = ("short_term_goals", "long_term_goals")
|
||||||
|
|
||||||
|
|
||||||
|
def goal_history(revisions: List[Dict[str, Any]]) -> Dict[str, List[dict]]:
|
||||||
|
"""Build a per-goal timeline of distinct values over time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
revisions: each ``{"fields": {<field>: value, ...}, "source": str,
|
||||||
|
"created_at": datetime}``, in ascending chronological order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"short_term_goals": [{"value", "source", "at"}...],
|
||||||
|
"long_term_goals": [...]}`` — one entry per *change*; consecutive
|
||||||
|
identical values are collapsed so the timeline shows only when a goal
|
||||||
|
actually moved.
|
||||||
|
"""
|
||||||
|
timelines: Dict[str, List[dict]] = {f: [] for f in GOAL_FIELDS}
|
||||||
|
last: Dict[str, Any] = {f: _SENTINEL for f in GOAL_FIELDS}
|
||||||
|
|
||||||
|
for rev in revisions:
|
||||||
|
fields = rev.get("fields") or {}
|
||||||
|
for field in GOAL_FIELDS:
|
||||||
|
value = fields.get(field)
|
||||||
|
if value == last[field]:
|
||||||
|
continue
|
||||||
|
last[field] = value
|
||||||
|
timelines[field].append(
|
||||||
|
{
|
||||||
|
"value": value,
|
||||||
|
"source": rev.get("source"),
|
||||||
|
"at": rev.get("created_at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return timelines
|
||||||
|
|
||||||
|
|
||||||
|
class _Sentinel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Distinct from None so a first snapshot whose goal is None still emits once.
|
||||||
|
_SENTINEL = _Sentinel()
|
||||||
@@ -124,20 +124,36 @@ class ReflectionCoach:
|
|||||||
return messages
|
return messages
|
||||||
|
|
||||||
async def _call_model(
|
async def _call_model(
|
||||||
self, profile: Dict[str, Any], messages: List[Dict[str, str]]
|
self,
|
||||||
|
profile: Dict[str, Any],
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
system_suffix: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
response = await self.client.messages.create(
|
response = await self.client.messages.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_tokens=MAX_TOKENS,
|
max_tokens=MAX_TOKENS,
|
||||||
system=SYSTEM_PROMPT.format(
|
system=SYSTEM_PROMPT.format(
|
||||||
profile=self.profile_context(profile)
|
profile=self.profile_context(profile)
|
||||||
),
|
)
|
||||||
|
+ system_suffix,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
)
|
)
|
||||||
return response.content[0].text
|
return response.content[0].text
|
||||||
|
|
||||||
|
# Optional focus steers (Phase 5). Appended to the system prompt.
|
||||||
|
FOCUS_STEERS = {
|
||||||
|
"goals": (
|
||||||
|
"\n\nFOCUS: Concentrate this turn on helping the person sharpen "
|
||||||
|
"their near-term and long-term goals — make them concrete and "
|
||||||
|
"theirs. Still a mirror: clarify what THEY said, never assign goals."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async def reflect(
|
async def reflect(
|
||||||
self, profile: Dict[str, Any], history: List[Dict[str, str]]
|
self,
|
||||||
|
profile: Dict[str, Any],
|
||||||
|
history: List[Dict[str, str]],
|
||||||
|
focus: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Produce one coach turn.
|
"""Produce one coach turn.
|
||||||
|
|
||||||
@@ -156,9 +172,10 @@ class ReflectionCoach:
|
|||||||
ReflectionError on API failure or repeated parse failure.
|
ReflectionError on API failure or repeated parse failure.
|
||||||
"""
|
"""
|
||||||
messages = self._build_messages(history)
|
messages = self._build_messages(history)
|
||||||
|
steer = self.FOCUS_STEERS.get(focus, "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = await self._call_model(profile, messages)
|
raw = await self._call_model(profile, messages, system_suffix=steer)
|
||||||
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
|
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error
|
||||||
raise ReflectionError(f"Anthropic API call failed: {exc}") from exc
|
raise ReflectionError(f"Anthropic API call failed: {exc}") from exc
|
||||||
|
|
||||||
@@ -170,7 +187,9 @@ class ReflectionCoach:
|
|||||||
{"role": "user", "content": RETRY_REMINDER},
|
{"role": "user", "content": RETRY_REMINDER},
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
raw_retry = await self._call_model(profile, retry)
|
raw_retry = await self._call_model(
|
||||||
|
profile, retry, system_suffix=steer
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise ReflectionError(
|
raise ReflectionError(
|
||||||
f"Anthropic API call failed on retry: {exc}"
|
f"Anthropic API call failed on retry: {exc}"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Phase 5 smart tagging: suggest which foundation a logged task builds toward.
|
||||||
|
|
||||||
|
The suggestion is exactly that — a suggestion the person confirms (by posting a
|
||||||
|
task mapping). The tagger reads the person's own foundation text and the task
|
||||||
|
label and proposes the best-fit foundation with a short rationale.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
from app.services.foundations import FOUNDATIONS
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||||
|
MAX_TOKENS = 400
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You help a person tag a unit of work to one of their own self-discovery "foundations". You SUGGEST; the person confirms — never decide for them.
|
||||||
|
|
||||||
|
THEIR FOUNDATIONS (their own words):
|
||||||
|
{foundations}
|
||||||
|
|
||||||
|
Given a task, pick the ONE foundation it most plausibly builds toward. If it is genuinely unclear, pick the closest and mark confidence low. Do not invent foundations.
|
||||||
|
|
||||||
|
Valid foundation keys: love, strength, mission, vocation, short_term, long_term.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON, no markdown fences:
|
||||||
|
{{
|
||||||
|
"foundation": "<one of the valid keys>",
|
||||||
|
"rationale": "one short sentence, addressed to the person (you/your)",
|
||||||
|
"confidence": "high | medium | low"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
RETRY_REMINDER = (
|
||||||
|
"Your previous response could not be parsed. Respond ONLY with the single "
|
||||||
|
"valid JSON object described — no preamble, no markdown fences."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaggingError(Exception):
|
||||||
|
"""Raised when tagging fails (API error or unparseable/invalid output)."""
|
||||||
|
|
||||||
|
|
||||||
|
class FoundationTagger:
|
||||||
|
"""Suggests a foundation for a task label."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = DEFAULT_MODEL):
|
||||||
|
if not api_key:
|
||||||
|
raise TaggingError(
|
||||||
|
"ANTHROPIC_API_KEY is not set; cannot suggest a foundation."
|
||||||
|
)
|
||||||
|
self.model = model
|
||||||
|
self.client = AsyncAnthropic(api_key=api_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _foundations_block(foundations: Dict[str, str]) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
f"- {key} ({FOUNDATIONS[key]}): {foundations.get(key) or '(none)'}"
|
||||||
|
for key in FOUNDATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _call(self, system: str, messages: list) -> str:
|
||||||
|
resp = await self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=MAX_TOKENS,
|
||||||
|
system=system,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
return resp.content[0].text
|
||||||
|
|
||||||
|
async def suggest(
|
||||||
|
self, task_label: str, foundations: Dict[str, str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return ``{"foundation", "rationale", "confidence"}``. The foundation
|
||||||
|
is guaranteed to be a valid key. Raises TaggingError on failure."""
|
||||||
|
system = SYSTEM_PROMPT.format(
|
||||||
|
foundations=self._foundations_block(foundations)
|
||||||
|
)
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": f"Task: {task_label}"}
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
raw = await self._call(system, messages)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise TaggingError(f"Anthropic API call failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._parse(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
retry = messages + [
|
||||||
|
{"role": "assistant", "content": raw},
|
||||||
|
{"role": "user", "content": RETRY_REMINDER},
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
raw_retry = await self._call(system, retry)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise TaggingError(
|
||||||
|
f"Anthropic API call failed on retry: {exc}"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
return self._parse(raw_retry)
|
||||||
|
except (json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise TaggingError(
|
||||||
|
f"Model did not return a valid suggestion: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_fences(text: str) -> str:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
nl = s.find("\n")
|
||||||
|
if nl != -1:
|
||||||
|
s = s[nl + 1 :]
|
||||||
|
if s.rstrip().endswith("```"):
|
||||||
|
s = s.rstrip()[:-3]
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse(cls, raw: str) -> Dict[str, Any]:
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
raise ValueError("empty response")
|
||||||
|
data = json.loads(cls._strip_fences(raw))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("not an object")
|
||||||
|
foundation = data.get("foundation")
|
||||||
|
if foundation not in FOUNDATIONS:
|
||||||
|
raise ValueError(f"invalid foundation: {foundation!r}")
|
||||||
|
confidence = data.get("confidence")
|
||||||
|
if confidence not in ("high", "medium", "low"):
|
||||||
|
confidence = "low"
|
||||||
|
rationale = data.get("rationale")
|
||||||
|
if not isinstance(rationale, str):
|
||||||
|
rationale = ""
|
||||||
|
return {
|
||||||
|
"foundation": foundation,
|
||||||
|
"rationale": rationale.strip(),
|
||||||
|
"confidence": confidence,
|
||||||
|
}
|
||||||
@@ -148,6 +148,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="back-link" style="text-align:center;margin-top:10px">
|
<p class="back-link" style="text-align:center;margin-top:10px">
|
||||||
<a href="/static/dashboard.html">Where your time goes →</a>
|
<a href="/static/dashboard.html">Where your time goes →</a>
|
||||||
|
</p>
|
||||||
|
<p class="back-link" style="text-align:center;margin-top:10px">
|
||||||
|
<a href="/static/visuals.html">See your profile visualized →</a>
|
||||||
</p>`;
|
</p>`;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
|
|||||||
@@ -398,6 +398,30 @@ textarea.edit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viz-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viz {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.goal-cols {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bars {
|
.bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ImpactFlow — Your Profile, Visualized</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script src="/static/auth.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="brand">ImpactFlow · Your Profile, Visualized</div>
|
||||||
|
<div id="content"><p>Loading…</p></div>
|
||||||
|
<p class="back-link"><a href="/static/profile.html">← Back to your profile</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SVGNS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
function el(tag, attrs, text) {
|
||||||
|
const n = document.createElementNS(SVGNS, tag);
|
||||||
|
for (const k in attrs) n.setAttribute(k, attrs[k]);
|
||||||
|
if (text != null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Ikigai: four overlapping circles ----
|
||||||
|
function ikigaiSvg(p) {
|
||||||
|
const W = 460, H = 460, cx = W / 2, cy = H / 2, r = 130, off = 78;
|
||||||
|
const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz" });
|
||||||
|
const circles = [
|
||||||
|
{ dx: 0, dy: -off, fill: "#c9a84c", label: "What you love", key: "love_summary" },
|
||||||
|
{ dx: off, dy: 0, fill: "#2e7d32", label: "Good at", key: "strength_summary" },
|
||||||
|
{ dx: 0, dy: off, fill: "#1c3049", label: "World needs", key: "mission_summary" },
|
||||||
|
{ dx: -off, dy: 0, fill: "#a0522d", label: "Paid for", key: "vocation_summary" },
|
||||||
|
];
|
||||||
|
circles.forEach((c) => {
|
||||||
|
svg.appendChild(
|
||||||
|
el("circle", {
|
||||||
|
cx: cx + c.dx, cy: cy + c.dy, r,
|
||||||
|
fill: c.fill, "fill-opacity": "0.22",
|
||||||
|
stroke: c.fill, "stroke-opacity": "0.7", "stroke-width": "1.5",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
circles.forEach((c) => {
|
||||||
|
const lx = cx + c.dx * 1.7;
|
||||||
|
const ly = cy + c.dy * 1.7 + (c.dy === 0 ? 4 : 0);
|
||||||
|
svg.appendChild(
|
||||||
|
el("text", {
|
||||||
|
x: lx, y: ly, "text-anchor": "middle",
|
||||||
|
"font-size": "14", "font-weight": "600", fill: "#0d1b2a",
|
||||||
|
}, c.label)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
svg.appendChild(
|
||||||
|
el("text", { x: cx, y: cy + 4, "text-anchor": "middle", "font-size": "15", "font-weight": "700", fill: "#0d1b2a" }, "Ikigai")
|
||||||
|
);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Enneagram: 9 points, inner triangle + hexad, type/wing highlighted.
|
||||||
|
// We keep callouts in plain language rather than headlining a type number.
|
||||||
|
function enneagramSvg(p) {
|
||||||
|
const W = 420, H = 420, cx = W / 2, cy = H / 2, R = 160;
|
||||||
|
const deg = { 9: 0, 1: 40, 2: 80, 3: 120, 4: 160, 5: 200, 6: 240, 7: 280, 8: 320 };
|
||||||
|
const pt = (n) => {
|
||||||
|
const t = (deg[n] * Math.PI) / 180;
|
||||||
|
return [cx + R * Math.sin(t), cy - R * Math.cos(t)];
|
||||||
|
};
|
||||||
|
const svg = el("svg", { viewBox: `0 0 ${W} ${H}`, class: "viz" });
|
||||||
|
svg.appendChild(el("circle", { cx, cy, r: R, fill: "none", stroke: "#1c3049", "stroke-opacity": "0.3", "stroke-width": "1.5" }));
|
||||||
|
const line = (a, b) => {
|
||||||
|
const [x1, y1] = pt(a), [x2, y2] = pt(b);
|
||||||
|
svg.appendChild(el("line", { x1, y1, x2, y2, stroke: "#1c3049", "stroke-opacity": "0.25", "stroke-width": "1.2" }));
|
||||||
|
};
|
||||||
|
[[9, 3], [3, 6], [6, 9]].forEach(([a, b]) => line(a, b));
|
||||||
|
[[1, 4], [4, 2], [2, 8], [8, 5], [5, 7], [7, 1]].forEach(([a, b]) => line(a, b));
|
||||||
|
|
||||||
|
const type = p.probable_type, wing = p.wing;
|
||||||
|
for (let n = 1; n <= 9; n++) {
|
||||||
|
const [x, y] = pt(n);
|
||||||
|
const isType = n === type, isWing = n === wing;
|
||||||
|
svg.appendChild(
|
||||||
|
el("circle", {
|
||||||
|
cx: x, cy: y, r: isType ? 17 : 12,
|
||||||
|
fill: isType ? "#c9a84c" : "#fff",
|
||||||
|
stroke: isWing ? "#c9a84c" : "#1c3049",
|
||||||
|
"stroke-width": isWing ? "3" : "1.5",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
svg.appendChild(
|
||||||
|
el("text", {
|
||||||
|
x, y: y + 4, "text-anchor": "middle", "font-size": "13",
|
||||||
|
"font-weight": isType ? "700" : "500",
|
||||||
|
fill: isType ? "#0d1b2a" : "#1c3049",
|
||||||
|
}, String(n))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIAD_LABEL = {
|
||||||
|
gut: "You lead with instinct and will",
|
||||||
|
heart: "You lead with feeling and connection",
|
||||||
|
head: "You lead with thought and perception",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadGoalHistory() {
|
||||||
|
try {
|
||||||
|
const res = await authedFetch("/discovery/profile/me/goal-history");
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineHtml(entries) {
|
||||||
|
if (!entries || !entries.length)
|
||||||
|
return `<p class="edit-help">No changes recorded yet.</p>`;
|
||||||
|
return entries
|
||||||
|
.map((e) => {
|
||||||
|
const when = e.at ? new Date(e.at).toLocaleDateString() : "";
|
||||||
|
return `<div class="card" style="margin-bottom:12px">
|
||||||
|
<p style="margin:0 0 6px"><span class="dot ${
|
||||||
|
e.source === "extraction" ? "medium" : "high"
|
||||||
|
}"></span> <strong>${escapeHtml(e.source || "")}</strong> · ${when}</p>
|
||||||
|
<p style="margin:0">${escapeHtml(e.value) || "—"}</p>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const content = document.getElementById("content");
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
const res = await authedFetch("/discovery/profile/me");
|
||||||
|
if (!res.ok) throw new Error("Profile not found");
|
||||||
|
p = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = `<div class="error-box">${escapeHtml(e.message)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<p class="section-label">Your Ikigai</p>
|
||||||
|
<div id="ikigai" class="viz-wrap"></div>
|
||||||
|
<div class="ikigai-grid">
|
||||||
|
<div class="card"><h3>♥ What you love</h3><p>${escapeHtml(p.love_summary) || "—"}</p></div>
|
||||||
|
<div class="card"><h3>★ What you're good at</h3><p>${escapeHtml(p.strength_summary) || "—"}</p></div>
|
||||||
|
<div class="card"><h3>◆ What the world needs</h3><p>${escapeHtml(p.mission_summary) || "—"}</p></div>
|
||||||
|
<div class="card"><h3>$ What you can be paid for</h3><p>${escapeHtml(p.vocation_summary) || "—"}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-label" style="margin-top:30px">Your centre of gravity</p>
|
||||||
|
<div id="enneagram" class="viz-wrap"></div>
|
||||||
|
<div class="triad-block"><p>${escapeHtml(
|
||||||
|
TRIAD_LABEL[p.triad] || "Your pattern"
|
||||||
|
)}. This map highlights where your energy sits and the point it leans toward.</p></div>
|
||||||
|
|
||||||
|
<p class="section-label" style="margin-top:30px">How your goals have evolved</p>
|
||||||
|
<div class="goal-cols">
|
||||||
|
<div><p class="edit-help">Near-term (6–12 months)</p><div id="st"></div></div>
|
||||||
|
<div><p class="edit-help">Long-term (3–5 years)</p><div id="lt"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById("ikigai").appendChild(ikigaiSvg(p));
|
||||||
|
document.getElementById("enneagram").appendChild(enneagramSvg(p));
|
||||||
|
|
||||||
|
const hist = await loadGoalHistory();
|
||||||
|
document.getElementById("st").innerHTML = timelineHtml(hist && hist.short_term_goals);
|
||||||
|
document.getElementById("lt").innerHTML = timelineHtml(hist && hist.long_term_goals);
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for Phase 5 endpoints: goal-history and smart-tagging suggestion."""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
API_KEY = {"X-API-Key": "test-api-key"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_profile() -> str:
|
||||||
|
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import DiscoveryConversation, DiscoveryProfile
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await ensure_api_key_admin(db)
|
||||||
|
conv = DiscoveryConversation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=API_KEY_ADMIN_ID,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(conv)
|
||||||
|
await db.commit()
|
||||||
|
profile = DiscoveryProfile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=API_KEY_ADMIN_ID,
|
||||||
|
conversation_id=conv.id,
|
||||||
|
generated_at=datetime.now(timezone.utc),
|
||||||
|
triad="gut",
|
||||||
|
short_term_goals="run a pilot",
|
||||||
|
long_term_goals="statewide outfit",
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.commit()
|
||||||
|
return profile.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_revision(profile_id, short, source, day):
|
||||||
|
from app.auth import API_KEY_ADMIN_ID
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import ProfileRevision
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
db.add(
|
||||||
|
ProfileRevision(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
profile_id=profile_id,
|
||||||
|
user_id=API_KEY_ADMIN_ID,
|
||||||
|
source=source,
|
||||||
|
fields_json=json.dumps(
|
||||||
|
{"short_term_goals": short, "long_term_goals": "statewide outfit"}
|
||||||
|
),
|
||||||
|
note=None,
|
||||||
|
created_at=datetime(2026, 6, day, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- goal history ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_goal_history_timeline(app_client):
|
||||||
|
pid = await _seed_profile()
|
||||||
|
await _add_revision(pid, "run a pilot", "extraction", 1)
|
||||||
|
await _add_revision(pid, "run a pilot", "manual_edit", 2) # unchanged
|
||||||
|
await _add_revision(pid, "launch fall cohort", "reflection", 3)
|
||||||
|
|
||||||
|
r = await app_client.get(
|
||||||
|
"/discovery/profile/me/goal-history", headers=API_KEY
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
st = r.json()["short_term_goals"]
|
||||||
|
assert [e["value"] for e in st] == ["run a pilot", "launch fall cohort"]
|
||||||
|
assert st[-1]["source"] == "reflection"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_goal_history_without_profile_404(app_client):
|
||||||
|
r = await app_client.get(
|
||||||
|
"/discovery/profile/me/goal-history", headers=API_KEY
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---- smart tagging ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTagger:
|
||||||
|
result = {"foundation": "short_term", "rationale": "builds your pilot", "confidence": "high"}
|
||||||
|
|
||||||
|
def __init__(self, api_key=None, model=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def suggest(self, task_label, foundations):
|
||||||
|
FakeTagger.last_label = task_label
|
||||||
|
return dict(FakeTagger.result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def fake_tagger(monkeypatch, app_client):
|
||||||
|
monkeypatch.setattr("app.routers.integration.FoundationTagger", FakeTagger)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_foundation(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/integration/suggest-foundation",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"task_label": "practice welding for the cohort"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["foundation"] == "short_term"
|
||||||
|
assert body["label"] # human label filled in
|
||||||
|
assert body["confidence"] == "high"
|
||||||
|
assert FakeTagger.last_label == "practice welding for the cohort"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_foundation_empty_label_400(app_client):
|
||||||
|
await _seed_profile()
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/integration/suggest-foundation",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"task_label": " "},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_foundation_without_profile_404(app_client):
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/integration/suggest-foundation",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"task_label": "anything"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_suggest_requires_auth(app_client):
|
||||||
|
r = await app_client.post(
|
||||||
|
"/discovery/integration/suggest-foundation",
|
||||||
|
json={"task_label": "x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Unit tests for the pure goal-evolution aggregator."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.services.profile_history import goal_history
|
||||||
|
|
||||||
|
|
||||||
|
def _rev(short, long_, source, day):
|
||||||
|
return {
|
||||||
|
"fields": {"short_term_goals": short, "long_term_goals": long_},
|
||||||
|
"source": source,
|
||||||
|
"created_at": datetime(2026, 6, day, tzinfo=timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapses_consecutive_identical_values():
|
||||||
|
revs = [
|
||||||
|
_rev("a", "x", "extraction", 1),
|
||||||
|
_rev("a", "x", "manual_edit", 2), # no change -> skipped
|
||||||
|
_rev("b", "x", "reflection", 3), # short changed
|
||||||
|
]
|
||||||
|
h = goal_history(revs)
|
||||||
|
assert [e["value"] for e in h["short_term_goals"]] == ["a", "b"]
|
||||||
|
assert [e["value"] for e in h["long_term_goals"]] == ["x"]
|
||||||
|
assert h["short_term_goals"][1]["source"] == "reflection"
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_field_tracked_independently():
|
||||||
|
revs = [
|
||||||
|
_rev("a", "x", "extraction", 1),
|
||||||
|
_rev("a", "y", "reflection", 2), # only long changed
|
||||||
|
]
|
||||||
|
h = goal_history(revs)
|
||||||
|
assert len(h["short_term_goals"]) == 1
|
||||||
|
assert [e["value"] for e in h["long_term_goals"]] == ["x", "y"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_revisions_yield_empty_timelines():
|
||||||
|
h = goal_history([])
|
||||||
|
assert h == {"short_term_goals": [], "long_term_goals": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_value_emitted_even_if_none():
|
||||||
|
revs = [_rev(None, None, "extraction", 1), _rev("a", None, "reflection", 2)]
|
||||||
|
h = goal_history(revs)
|
||||||
|
assert [e["value"] for e in h["short_term_goals"]] == [None, "a"]
|
||||||
|
# long stayed None throughout -> a single None entry
|
||||||
|
assert [e["value"] for e in h["long_term_goals"]] == [None]
|
||||||
@@ -55,8 +55,10 @@ class FakeCoach:
|
|||||||
def __init__(self, api_key=None, model=None):
|
def __init__(self, api_key=None, model=None):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def reflect(self, profile, history):
|
async def reflect(self, profile, history, focus=""):
|
||||||
FakeCoach.calls.append({"profile": profile, "history": list(history)})
|
FakeCoach.calls.append(
|
||||||
|
{"profile": profile, "history": list(history), "focus": focus}
|
||||||
|
)
|
||||||
return dict(FakeCoach.response)
|
return dict(FakeCoach.response)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +182,17 @@ async def test_empty_message_after_opener_returns_400(app_client):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reflect_passes_focus_to_coach(app_client):
|
||||||
|
"""Phase 5: a goal-refinement focus reaches the coach."""
|
||||||
|
await _seed_profile()
|
||||||
|
await app_client.post(
|
||||||
|
"/discovery/profile/me/reflect",
|
||||||
|
headers=API_KEY,
|
||||||
|
json={"message": "let's sharpen my goals", "focus": "goals"},
|
||||||
|
)
|
||||||
|
assert FakeCoach.calls[-1]["focus"] == "goals"
|
||||||
|
|
||||||
|
|
||||||
async def test_reflect_requires_auth(app_client):
|
async def test_reflect_requires_auth(app_client):
|
||||||
r = await app_client.post("/discovery/profile/me/reflect", json={})
|
r = await app_client.post("/discovery/profile/me/reflect", json={})
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|||||||
@@ -75,6 +75,20 @@ def test_profile_page_links_to_dashboard():
|
|||||||
assert "/static/dashboard.html" in html
|
assert "/static/dashboard.html" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_visuals_page_reads_profile_and_goal_history():
|
||||||
|
"""Phase 5 visualizations read the profile and goal history via authedFetch."""
|
||||||
|
html = Path("app/static/visuals.html").read_text(encoding="utf-8")
|
||||||
|
assert "/discovery/profile/me" in html
|
||||||
|
assert "/discovery/profile/me/goal-history" in html
|
||||||
|
assert "authedFetch" in html
|
||||||
|
assert "user_id" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_page_links_to_visuals():
|
||||||
|
html = Path("app/static/profile.html").read_text(encoding="utf-8")
|
||||||
|
assert "/static/visuals.html" in html
|
||||||
|
|
||||||
|
|
||||||
def test_auth_helper_sends_credentials_and_refreshes():
|
def test_auth_helper_sends_credentials_and_refreshes():
|
||||||
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
js = Path("app/static/auth.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user