Skip to content

Commit 2de4cb1

Browse files
committed
Merge branch 'feature/issue-301-goal-email-insight-enrichment' into dev
2 parents 21c1051 + 637fb39 commit 2de4cb1

9 files changed

Lines changed: 232 additions & 6 deletions

File tree

coaching/prompts/goal_created_email_insight/system.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,5 @@ CONTENT GUIDELINES:
5151
- Do not invent facts not grounded in input context
5252
- Keep text ready for direct embedding into email templates
5353
- Avoid sensitive, risky, or policy-violating guidance
54+
- When vision, purpose, core values, goal-linked strategies, or measures are provided, you may briefly
55+
connect the goal to that context (alignment or a gentle gap); keep it concise and appropriate for email

coaching/prompts/goal_created_email_insight/user.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ Context:
77
- Tenant business name: {business_name}
88
- User display name: {user_name}
99
- Goal id: {goal_id}
10+
- Goal (full record as available): {goal}
1011
- Goal title: {goal_title}
1112
- Goal description: {goal_description}
13+
- Goal intent (WHAT/WHY if present): {goal_intent}
14+
- Business vision: {vision}
15+
- Business purpose: {purpose}
16+
- Core values: {core_values}
17+
- Strategies linked to this goal (may be empty): {existing_strategies_for_goal}
18+
- Measures linked to this goal (may be empty): {measures_formatted_for_goal}
1219
- Locale hint: {locale}
1320

1421
Required behavior:
@@ -20,7 +27,8 @@ Required behavior:
2027
- one paragraph block
2128
- one list block with 2-4 short items
2229
4. Add one cta block when a safe and useful next action exists.
23-
5. Keep suggestions specific to this goal context.
30+
5. Keep suggestions specific to this goal context; use foundation and strategy/measure context when helpful,
31+
without forcing a long alignment section.
2432
6. Do not output HTML or XML in any field.
2533
7. Do not add extra keys beyond the contract.
2634

coaching/src/core/parameter_registry.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,28 @@ def _register(param: ParameterDefinition) -> ParameterDefinition:
10031003
)
10041004
)
10051005

1006+
_register(
1007+
ParameterDefinition(
1008+
name="measures_for_goal",
1009+
param_type=ParameterType.LIST,
1010+
description="Measures linked to the goal from payload goal_id (goalId or connections.goalIds).",
1011+
default=[],
1012+
retrieval_method="get_measures_summary",
1013+
extraction_path="measures_for_goal",
1014+
)
1015+
)
1016+
1017+
_register(
1018+
ParameterDefinition(
1019+
name="measures_formatted_for_goal",
1020+
param_type=ParameterType.STRING,
1021+
description="Formatted bullet list of measures linked to payload goal_id.",
1022+
default="No measures linked to this goal yet.",
1023+
retrieval_method="get_measures_summary",
1024+
extraction_path="measures_formatted_for_goal",
1025+
)
1026+
)
1027+
10061028
# -----------------------------------------------------------------------------
10071029
# Action Parameters (from get_action_by_id)
10081030
# -----------------------------------------------------------------------------

coaching/src/core/retrieval_method_registry.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,64 @@ async def my_method(context: RetrievalContext) -> dict[str, Any]:
3535
logger = structlog.get_logger()
3636

3737

38+
def _strategies_linked_to_goal(
39+
strategies: list[dict[str, Any]],
40+
goal_id: str | None,
41+
) -> list[dict[str, Any]]:
42+
"""Return strategies whose goalId matches the payload goal (if any)."""
43+
if not goal_id:
44+
return []
45+
gid = str(goal_id)
46+
return [s for s in strategies if str(s.get("goalId", "")) == gid]
47+
48+
49+
def _format_strategy_lines(strategies: list[dict[str, Any]]) -> str:
50+
"""Human-readable bullet list for prompts (email / coaching)."""
51+
if not strategies:
52+
return "No strategies linked to this goal yet."
53+
lines: list[str] = []
54+
for s in strategies:
55+
name = s.get("name") or "Unnamed strategy"
56+
status = s.get("status") or ""
57+
desc = (s.get("description") or "").strip()
58+
head = f"- {name}" + (f" ({status})" if status else "")
59+
if desc:
60+
head += f": {desc[:280]}"
61+
lines.append(head)
62+
return "\n".join(lines)
63+
64+
65+
def _measures_linked_to_goal(
66+
measures: list[dict[str, Any]],
67+
goal_id: str | None,
68+
) -> list[dict[str, Any]]:
69+
"""Return measures linked to the goal via goalId or connections.goalIds."""
70+
if not goal_id:
71+
return []
72+
gid = str(goal_id)
73+
out: list[dict[str, Any]] = []
74+
for m in measures:
75+
if str(m.get("goalId", "")) == gid:
76+
out.append(m)
77+
continue
78+
conns = m.get("connections") or {}
79+
raw_goal_ids = conns.get("goalIds") or []
80+
if gid in {str(x) for x in raw_goal_ids}:
81+
out.append(m)
82+
return out
83+
84+
85+
def _format_measure_lines(measures: list[dict[str, Any]]) -> str:
86+
if not measures:
87+
return "No measures linked to this goal yet."
88+
lines: list[str] = []
89+
for m in measures:
90+
name = m.get("name") or "Unnamed measure"
91+
status = m.get("status") or ""
92+
lines.append(f"- {name}" + (f" ({status})" if status else ""))
93+
return "\n".join(lines)
94+
95+
3896
@dataclass
3997
class RetrievalContext:
4098
"""Context passed to retrieval methods.
@@ -789,6 +847,7 @@ async def get_strategy_by_id(context: RetrievalContext) -> dict[str, Any]:
789847
"strategies_count",
790848
"strategies_by_status",
791849
"strategies_by_type",
850+
"strategies_formatted",
792851
),
793852
)
794853
async def get_all_strategies(context: RetrievalContext) -> dict[str, Any]:
@@ -817,11 +876,16 @@ async def get_all_strategies(context: RetrievalContext) -> dict[str, Any]:
817876
by_type[stype] = []
818877
by_type[stype].append(s)
819878

879+
goal_id = context.payload.get("goal_id")
880+
linked = _strategies_linked_to_goal(strategies_list, goal_id if goal_id else None)
881+
strategies_formatted = _format_strategy_lines(linked)
882+
820883
return {
821884
"strategies": strategies_list,
822885
"strategies_count": len(strategies_list),
823886
"strategies_by_status": by_status,
824887
"strategies_by_type": by_type,
888+
"strategies_formatted": strategies_formatted,
825889
}
826890
except Exception as e:
827891
logger.error(
@@ -834,6 +898,7 @@ async def get_all_strategies(context: RetrievalContext) -> dict[str, Any]:
834898
"strategies_count": 0,
835899
"strategies_by_status": {},
836900
"strategies_by_type": {},
901+
"strategies_formatted": "No strategies linked to this goal yet.",
837902
}
838903

839904

@@ -850,6 +915,8 @@ async def get_all_strategies(context: RetrievalContext) -> dict[str, Any]:
850915
"measures_owner_breakdown",
851916
"measures_by_status",
852917
"at_risk_measures",
918+
"measures_for_goal",
919+
"measures_formatted_for_goal",
853920
),
854921
)
855922
async def get_measures_summary(context: RetrievalContext) -> dict[str, Any]:
@@ -883,6 +950,13 @@ async def get_measures_summary(context: RetrievalContext) -> dict[str, Any]:
883950
if status in ("at_risk", "behind"):
884951
at_risk.append(m)
885952

953+
goal_id = context.payload.get("goal_id")
954+
measures_for_goal = _measures_linked_to_goal(
955+
measures,
956+
goal_id if goal_id else None,
957+
)
958+
measures_formatted_for_goal = _format_measure_lines(measures_for_goal)
959+
886960
return {
887961
"measures_summary": data,
888962
"measures": measures,
@@ -893,6 +967,8 @@ async def get_measures_summary(context: RetrievalContext) -> dict[str, Any]:
893967
"measures_owner_breakdown": summary.get("byOwner", []),
894968
"measures_by_status": by_status,
895969
"at_risk_measures": at_risk,
970+
"measures_for_goal": measures_for_goal,
971+
"measures_formatted_for_goal": measures_formatted_for_goal,
896972
}
897973
except Exception as e:
898974
logger.error(
@@ -910,6 +986,8 @@ async def get_measures_summary(context: RetrievalContext) -> dict[str, Any]:
910986
"measures_owner_breakdown": [],
911987
"measures_by_status": {},
912988
"at_risk_measures": [],
989+
"measures_for_goal": [],
990+
"measures_formatted_for_goal": "No measures linked to this goal yet.",
913991
}
914992

915993

coaching/src/core/topic_registry.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,19 @@ class TopicDefinition:
360360
parameter_refs=(
361361
_req("goal_id"), # Required request parameter for goal context
362362
_opt_req("locale"), # Optional localization hint (default en-US)
363+
_onb("vision"), # Business foundation — alignment to vision
364+
_onb("purpose"), # Business foundation — alignment to purpose
365+
_onb("core_values"), # Business foundation — alignment to values
366+
_onb("business_name"), # Tenant/business display context
367+
_goal("goal"), # Full goal record (get_goal_by_id / template enrichment)
363368
_goal("goal_title"), # Auto-enriched from goal service
364369
_goal("goal_description"), # Auto-enriched from goal service
370+
_goal("goal_intent"), # Goal intent / WHY for alignment framing
371+
_strategies(
372+
"existing_strategies_for_goal"
373+
), # Goal-scoped strategies (strategies_formatted when goal_id set)
374+
_measures("measures_formatted_for_goal"), # Goal-scoped measures summary
365375
_user("user_name"), # User display name context
366-
_onb("business_name"), # Tenant/business display context
367376
),
368377
),
369378
# ========== Section 4: Strategic Planning AI (6 endpoints) ==========

coaching/src/core/topic_seed_data.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,15 +483,24 @@ class TopicSeedData:
483483
- Suggest next steps that are realistic and immediate
484484
- Do not invent facts not grounded in input context
485485
- Keep text ready for direct embedding into email templates
486-
- Avoid sensitive, risky, or policy-violating guidance""",
486+
- Avoid sensitive, risky, or policy-violating guidance
487+
- Where business foundation (vision, purpose, values) and goal-linked strategies/measures are provided,
488+
briefly reflect alignment or a constructive gap (without harsh judgment); stay email-appropriate.""",
487489
default_user_prompt="""Generate an email insight for the "goal created" trigger.
488490
489491
Context:
490492
- Tenant business name: {business_name}
491493
- User display name: {user_name}
492494
- Goal id: {goal_id}
495+
- Goal (full record as available): {goal}
493496
- Goal title: {goal_title}
494497
- Goal description: {goal_description}
498+
- Goal intent (WHAT/WHY if present): {goal_intent}
499+
- Business vision: {vision}
500+
- Business purpose: {purpose}
501+
- Core values: {core_values}
502+
- Strategies linked to this goal (may be empty): {existing_strategies_for_goal}
503+
- Measures linked to this goal (may be empty): {measures_formatted_for_goal}
495504
- Locale hint: {locale}
496505
497506
Required behavior:
@@ -501,7 +510,8 @@ class TopicSeedData:
501510
- one paragraph block
502511
- one list block with 2-4 short items
503512
4. Add one cta block when a safe and useful next action exists.
504-
5. Keep suggestions specific to this goal context.
513+
5. Keep suggestions specific to this goal context; use vision, purpose, values, strategies, and measures
514+
only when they add clear value (do not force a long alignment lecture).
505515
6. Do not output HTML or XML in any field.
506516
7. Do not add extra keys beyond the contract.
507517
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Tests for goal-scoped strategy/measure enrichment in retrieval methods."""
2+
3+
from unittest.mock import AsyncMock
4+
5+
import pytest
6+
from coaching.src.core.retrieval_method_registry import (
7+
RetrievalContext,
8+
get_all_strategies,
9+
get_measures_summary,
10+
)
11+
12+
13+
@pytest.mark.asyncio
14+
async def test_get_all_strategies_strategies_formatted_filters_by_goal_id() -> None:
15+
"""When goal_id is in the payload, strategies_formatted lists only matching strategies."""
16+
client = AsyncMock()
17+
client.get_strategies = AsyncMock(
18+
return_value=[
19+
{"id": "s1", "name": "Alpha", "goalId": "g1"},
20+
{"id": "s2", "name": "Beta", "goalId": "g2"},
21+
]
22+
)
23+
ctx = RetrievalContext(
24+
client=client,
25+
tenant_id="t1",
26+
user_id="u1",
27+
payload={"goal_id": "g1"},
28+
)
29+
result = await get_all_strategies(ctx)
30+
assert result["strategies_count"] == 2
31+
assert "Alpha" in result["strategies_formatted"]
32+
assert "Beta" not in result["strategies_formatted"]
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_get_all_strategies_without_goal_id_empty_formatted() -> None:
37+
"""Without goal_id, strategies_formatted is the empty-linked message (no spurious bullets)."""
38+
client = AsyncMock()
39+
client.get_strategies = AsyncMock(
40+
return_value=[{"id": "s1", "name": "Alpha", "goalId": "g1"}],
41+
)
42+
ctx = RetrievalContext(
43+
client=client,
44+
tenant_id="t1",
45+
user_id="u1",
46+
payload={},
47+
)
48+
result = await get_all_strategies(ctx)
49+
assert result["strategies_formatted"] == "No strategies linked to this goal yet."
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_get_measures_summary_measures_for_goal_filters() -> None:
54+
"""Measures linked by goalId or connections.goalIds appear in goal-scoped fields."""
55+
client = AsyncMock()
56+
client.get_measures_summary = AsyncMock(
57+
return_value={
58+
"measures": [
59+
{"id": "m1", "name": "M1", "status": "on_track", "goalId": "g1"},
60+
{
61+
"id": "m2",
62+
"name": "M2",
63+
"status": "on_track",
64+
"connections": {"goalIds": ["g2"]},
65+
},
66+
],
67+
"summary": {},
68+
"healthScore": 0,
69+
}
70+
)
71+
ctx = RetrievalContext(
72+
client=client,
73+
tenant_id="t1",
74+
user_id="u1",
75+
payload={"goal_id": "g1"},
76+
)
77+
result = await get_measures_summary(ctx)
78+
assert len(result["measures_for_goal"]) == 1
79+
assert result["measures_for_goal"][0]["name"] == "M1"
80+
assert "M1" in result["measures_formatted_for_goal"]
81+
assert "M2" not in result["measures_formatted_for_goal"]

coaching/tests/unit/core/test_topic_seed_data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,7 @@ def test_goal_created_email_insight_prompt_contract_constraints(self) -> None:
8787
assert '"schemaversion": "1.0"' in system_prompt
8888
assert "paragraph, list, cta" in system_prompt
8989
assert "return one json object only" in user_prompt
90+
assert "{vision}" in seed.default_user_prompt
91+
assert "{goal_intent}" in seed.default_user_prompt
92+
assert "{existing_strategies_for_goal}" in seed.default_user_prompt
93+
assert "{measures_formatted_for_goal}" in seed.default_user_prompt

docs/shared/Specifications/ai-api/email-insights-api-contract.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Email Insights AI API Contract Specification
22

3-
**Version:** 2.0
4-
**Last Updated:** March 27, 2026
3+
**Version:** 2.1
4+
**Last Updated:** April 8, 2026
55
**Status:** Approved for Full Cutover
66
**Scope:** Generic `email_insight` topics
77

@@ -11,6 +11,7 @@
1111

1212
## Revision Log
1313

14+
- 2026-04-08 - v2.1 - Document server-side enrichment parameters for `goal_created_email_insight` (foundation + goal-scoped strategies/measures)
1415
- 2026-03-27 - v2.0 - Full-cutover generic topic contract with required service token for enrichment API calls
1516
- 2026-03-25 - v1.0 - Initial approved contract for activity-driven email insights v1 pilot
1617

@@ -94,6 +95,17 @@ Cross-system request/response naming between PurposePath_Api and PurposePath_AI
9495
- AI service forwards token to standard backend user-facing API endpoints for enrichment.
9596
- Backend user-facing endpoints validate token through standard authentication/authorization components.
9697

98+
### 4.5 Server-side enrichment (`goal_created_email_insight`)
99+
100+
Orchestrators still supply topic input primarily via `goal_id` (and standard user/tenant context) plus `authContext` for enrichment API calls. The AI coaching service resolves additional **template parameters** (not required in the trigger `activityData` payload) using the same retrieval stack as other single-shot topics, including:
101+
102+
- Business foundation: `vision`, `purpose`, `core_values`, `business_name`
103+
- Goal: full `goal` record plus `goal_title`, `goal_description`, `goal_intent` where available
104+
- Goal-scoped strategies: `existing_strategies_for_goal` (formatted list of strategies whose `goalId` matches `goal_id`)
105+
- Goal-scoped measures: `measures_formatted_for_goal` (formatted list of measures linked via `goalId` or `connections.goalIds`)
106+
107+
Exact placeholder names and prompt wording live in topic seed data and deployed prompts; this section records **contractual expectation** that enrichment uses `goal_id` to scope strategies and measures.
108+
97109
---
98110

99111
## 5. AI Output Payload Contract (`purposepath.email-insight.v1`)

0 commit comments

Comments
 (0)