Skip to content

Commit 3fcd67b

Browse files
update to use new assisted facilitation approach
1 parent fa7bb47 commit 3fcd67b

File tree

7 files changed

+776
-82
lines changed

7 files changed

+776
-82
lines changed

doc/DISCOVERY.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,20 @@ The process can be more art than science. It's often messy, and can suffer from
1818
- The application generates follow up questions specific to participants / examples. Observations from other participants may be included to specifically probe for interesting disagreements.
1919
- The findings aggregated by participant/example are presented to the facilitator to _inform_ discussion (not to replace it). Since facilitators often don't know about the domain, this helps reduce cognitive load.
2020
- Participants autonomously own the process with the faciliator and workshop just providing the framework. They will organically identify common clusters of findings and themes.
21+
22+
## How assisted facilitation works (high level)
23+
24+
Assisted facilitation helps participants go deeper on each example and helps facilitators guide discussion without needing to be a domain expert.
25+
26+
### During participant review (per example)
27+
28+
- **Start simple, then go deeper**: each example begins with a baseline prompt (“what makes this effective or ineffective?”). As a participant responds, the application can propose a small number of follow-up questions that encourage deeper thinking (edge cases, missing info, boundary conditions, failure modes).
29+
- **Probe disagreements intentionally**: when different participants notice different things about the same example, follow-up questions can be tailored to surface the disagreement and clarify the underlying definition of “good” vs “bad”.
30+
- **Stop when coverage is good**: follow-up questions aren’t infinite; once the key angles have been explored (or a sensible limit is reached), the application stops proposing more so the group can move on.
31+
32+
### For the facilitator (across participants and examples)
33+
34+
- **Theme extraction and synthesis**: participant observations are summarized into a small set of themes and recurring patterns, both overall and broken down by participant and by example.
35+
- **Discussion-ready outputs**: the app surfaces key disagreements and provides short discussion prompts that help the facilitator run a productive conversation.
36+
- **Bridge to rubric creation**: the system can suggest candidate rubric questions (concrete “quality dimensions”) derived from the themes so the group can turn discovery insights into a rubric more quickly.
37+
- **Progress signals**: simple convergence indicators (how consistently themes appear across participants) help the facilitator judge when the group has enough shared understanding to move into rubric definition and annotation.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Add category column to discovery_questions table.
2+
3+
Adds:
4+
- category column to discovery_questions for coverage tracking
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
revision = "0007_discovery_question_category"
14+
down_revision = "0006_discovery_summaries_table"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
bind = op.get_bind()
21+
inspector = sa.inspect(bind)
22+
23+
if not inspector.has_table("discovery_questions"):
24+
return
25+
26+
columns = [c["name"] for c in inspector.get_columns("discovery_questions")]
27+
if "category" not in columns:
28+
op.add_column(
29+
"discovery_questions",
30+
sa.Column("category", sa.String(), nullable=True),
31+
)
32+
33+
34+
def downgrade() -> None:
35+
bind = op.get_bind()
36+
inspector = sa.inspect(bind)
37+
38+
if not inspector.has_table("discovery_questions"):
39+
return
40+
41+
columns = [c["name"] for c in inspector.get_columns("discovery_questions")]
42+
if "category" in columns:
43+
op.drop_column("discovery_questions", "category")
44+

server/database.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ class DiscoveryQuestionDB(Base):
214214
question_id = Column(String, nullable=False) # Stable ID per (user, trace), e.g. "q_1"
215215
prompt = Column(Text, nullable=False)
216216
placeholder = Column(Text, nullable=True)
217+
category = Column(String, nullable=True) # Coverage category: themes, edge_cases, boundary_conditions, failure_modes, missing_info, disagreements
217218
created_at = Column(DateTime, default=func.now())
218219

219220

server/routers/discovery.py

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,23 @@ class DiscoveryQuestion(BaseModel):
2525
id: str
2626
prompt: str
2727
placeholder: Optional[str] = None
28+
category: Optional[str] = None
29+
30+
31+
class DiscoveryCoverage(BaseModel):
32+
"""Coverage state for discovery questions."""
33+
34+
covered: List[str]
35+
missing: List[str]
2836

2937

3038
class DiscoveryQuestionsResponse(BaseModel):
31-
"""Response model for discovery questions."""
39+
"""Response model for discovery questions with coverage metadata."""
3240

3341
questions: List[DiscoveryQuestion]
42+
can_generate_more: bool = True
43+
stop_reason: Optional[str] = None
44+
coverage: DiscoveryCoverage
3445

3546

3647
class DiscoveryQuestionsModelConfig(BaseModel):
@@ -39,12 +50,39 @@ class DiscoveryQuestionsModelConfig(BaseModel):
3950
model_name: str
4051

4152

53+
class KeyDisagreementResponse(BaseModel):
54+
"""A disagreement between participants."""
55+
56+
theme: str
57+
trace_ids: List[str] = []
58+
viewpoints: List[str] = []
59+
60+
61+
class DiscussionPromptResponse(BaseModel):
62+
"""A facilitator discussion prompt."""
63+
64+
theme: str
65+
prompt: str
66+
67+
68+
class ConvergenceMetricsResponse(BaseModel):
69+
"""Cross-participant agreement metrics."""
70+
71+
theme_agreement: Dict[str, float] = {}
72+
overall_alignment_score: float = 0.0
73+
74+
4275
class DiscoverySummariesResponse(BaseModel):
4376
"""LLM-generated summaries of discovery findings for facilitators."""
4477

4578
overall: Dict[str, Any]
4679
by_user: List[Dict[str, Any]]
4780
by_trace: List[Dict[str, Any]]
81+
candidate_rubric_questions: List[str] = []
82+
key_disagreements: List[KeyDisagreementResponse] = []
83+
discussion_prompts: List[DiscussionPromptResponse] = []
84+
convergence: ConvergenceMetricsResponse = ConvergenceMetricsResponse()
85+
ready_for_rubric: bool = False
4886

4987

5088
@router.get(
@@ -59,8 +97,13 @@ async def get_discovery_questions(
5997
db: Session = Depends(get_db),
6098
) -> DiscoveryQuestionsResponse:
6199
svc = DiscoveryService(db)
62-
questions = svc.get_discovery_questions(workshop_id=workshop_id, trace_id=trace_id, user_id=user_id, append=append)
63-
return DiscoveryQuestionsResponse(questions=[DiscoveryQuestion(**q) for q in questions])
100+
result = svc.get_discovery_questions(workshop_id=workshop_id, trace_id=trace_id, user_id=user_id, append=append)
101+
return DiscoveryQuestionsResponse(
102+
questions=[DiscoveryQuestion(**q) for q in result["questions"]],
103+
can_generate_more=result.get("can_generate_more", True),
104+
stop_reason=result.get("stop_reason"),
105+
coverage=DiscoveryCoverage(**result.get("coverage", {"covered": [], "missing": []})),
106+
)
64107

65108

66109
@router.put("/{workshop_id}/discovery-questions-model")
@@ -74,28 +117,53 @@ async def update_discovery_questions_model(
74117
return {"message": "Discovery questions model updated", "model_name": model_name}
75118

76119

120+
def _build_summaries_response(payload: Dict[str, Any]) -> DiscoverySummariesResponse:
121+
"""Build a DiscoverySummariesResponse from a payload dict."""
122+
# Parse key_disagreements
123+
key_disagreements = []
124+
for d in payload.get("key_disagreements") or []:
125+
if isinstance(d, dict):
126+
key_disagreements.append(KeyDisagreementResponse(**d))
127+
128+
# Parse discussion_prompts
129+
discussion_prompts = []
130+
for p in payload.get("discussion_prompts") or []:
131+
if isinstance(p, dict):
132+
discussion_prompts.append(DiscussionPromptResponse(**p))
133+
134+
# Parse convergence
135+
convergence_data = payload.get("convergence") or {}
136+
if isinstance(convergence_data, dict):
137+
convergence = ConvergenceMetricsResponse(**convergence_data)
138+
else:
139+
convergence = ConvergenceMetricsResponse()
140+
141+
return DiscoverySummariesResponse(
142+
overall=payload.get("overall") or {},
143+
by_user=payload.get("by_user") or [],
144+
by_trace=payload.get("by_trace") or [],
145+
candidate_rubric_questions=payload.get("candidate_rubric_questions") or [],
146+
key_disagreements=key_disagreements,
147+
discussion_prompts=discussion_prompts,
148+
convergence=convergence,
149+
ready_for_rubric=payload.get("ready_for_rubric", False),
150+
)
151+
152+
77153
@router.post("/{workshop_id}/discovery-summaries", response_model=DiscoverySummariesResponse)
78154
async def generate_discovery_summaries(
79155
workshop_id: str, refresh: bool = False, db: Session = Depends(get_db)
80156
) -> DiscoverySummariesResponse:
81157
svc = DiscoveryService(db)
82158
payload = svc.generate_discovery_summaries(workshop_id=workshop_id, refresh=refresh)
83-
return DiscoverySummariesResponse(
84-
overall=payload.get("overall") or {},
85-
by_user=payload.get("by_user") or [],
86-
by_trace=payload.get("by_trace") or [],
87-
)
159+
return _build_summaries_response(payload)
88160

89161

90162
@router.get("/{workshop_id}/discovery-summaries", response_model=DiscoverySummariesResponse)
91163
async def get_discovery_summaries(workshop_id: str, db: Session = Depends(get_db)) -> DiscoverySummariesResponse:
92164
svc = DiscoveryService(db)
93165
payload = svc.get_discovery_summaries(workshop_id=workshop_id)
94-
return DiscoverySummariesResponse(
95-
overall=payload.get("overall") or {},
96-
by_user=payload.get("by_user") or [],
97-
by_trace=payload.get("by_trace") or [],
98-
)
166+
return _build_summaries_response(payload)
99167

100168

101169
@router.post("/{workshop_id}/findings", response_model=DiscoveryFinding)

server/services/database_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,7 @@ def get_discovery_questions(self, workshop_id: str, trace_id: str, user_id: str)
621621
"id": r.question_id,
622622
"prompt": r.prompt,
623623
"placeholder": r.placeholder,
624+
"category": getattr(r, "category", None),
624625
"created_at": r.created_at,
625626
}
626627
for r in rows
@@ -633,6 +634,7 @@ def add_discovery_question(
633634
user_id: str,
634635
prompt: str,
635636
placeholder: Optional[str] = None,
637+
category: Optional[str] = None,
636638
) -> Dict[str, Any]:
637639
"""Append a new generated discovery question for a specific (workshop, trace, user)."""
638640
# Compute next stable question_id, reserving q_1 for the fixed baseline question.
@@ -664,6 +666,7 @@ def add_discovery_question(
664666
question_id=question_id,
665667
prompt=prompt,
666668
placeholder=placeholder,
669+
category=category,
667670
)
668671
self.db.add(row)
669672
self.db.commit()
@@ -673,6 +676,7 @@ def add_discovery_question(
673676
"id": row.question_id,
674677
"prompt": row.prompt,
675678
"placeholder": row.placeholder,
679+
"category": row.category,
676680
"created_at": row.created_at,
677681
}
678682

0 commit comments

Comments
 (0)