Skip to content

Commit 4c467b5

Browse files
authored
Merge pull request #14 from msitarzewski/messaging-refinement
Reposition README as AI infrastructure, add CLI citations
2 parents fc09b4c + 4085d8c commit 4c467b5

9 files changed

Lines changed: 326 additions & 83 deletions

File tree

README.md

Lines changed: 191 additions & 59 deletions
Large diffs are not rendered by default.

src/duh/api/routes/ask.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,15 @@ async def _handle_consensus( # type: ignore[no-untyped-def]
104104
from duh.cli.app import _run_consensus
105105

106106
use_native_search = config.tools.enabled and config.tools.web_search.native
107-
decision, confidence, rigor, dissent, cost, _overview = await _run_consensus(
107+
(
108+
decision,
109+
confidence,
110+
rigor,
111+
dissent,
112+
cost,
113+
_overview,
114+
_citations,
115+
) = await _run_consensus(
108116
body.question,
109117
config,
110118
pm,
@@ -176,9 +184,15 @@ async def _handle_decompose(body: AskRequest, config, pm) -> AskResponse: # typ
176184
if len(subtask_specs) == 1:
177185
from duh.cli.app import _run_consensus
178186

179-
decision, confidence, rigor, dissent, cost, _overview = await _run_consensus(
180-
body.question, config, pm
181-
)
187+
(
188+
decision,
189+
confidence,
190+
rigor,
191+
dissent,
192+
cost,
193+
_overview,
194+
_citations,
195+
) = await _run_consensus(body.question, config, pm)
182196
return AskResponse(
183197
decision=decision,
184198
confidence=confidence,

src/duh/cli/app.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,10 +210,12 @@ async def _run_consensus(
210210
proposer_override: str | None = None,
211211
challengers_override: list[str] | None = None,
212212
web_search: bool = False,
213-
) -> tuple[str, float, float, str | None, float, str | None]:
213+
) -> tuple[
214+
str, float, float, str | None, float, str | None, list[dict[str, str | None]]
215+
]:
214216
"""Run the full consensus loop.
215217
216-
Returns (decision, confidence, rigor, dissent, total_cost, overview).
218+
Returns (decision, confidence, rigor, dissent, total_cost, overview, citations).
217219
"""
218220
from duh.consensus.convergence import check_convergence
219221
from duh.consensus.handlers import (
@@ -332,13 +334,25 @@ async def _run_consensus(
332334
if display and ctx.tool_calls_log:
333335
display.show_tool_use(ctx.tool_calls_log)
334336

337+
# Collect all citations across rounds
338+
all_citations: list[dict[str, str | None]] = []
339+
for rr in ctx.round_history:
340+
all_citations.extend(rr.proposal_citations)
341+
for ch in rr.challenges:
342+
all_citations.extend(ch.citations)
343+
# Include current round (may not be archived yet)
344+
all_citations.extend(ctx.proposal_citations)
345+
for ch in ctx.challenges:
346+
all_citations.extend(ch.citations)
347+
335348
return (
336349
ctx.decision or "",
337350
ctx.confidence,
338351
ctx.rigor,
339352
ctx.dissent,
340353
pm.total_cost,
341354
ctx.overview,
355+
all_citations,
342356
)
343357

344358

@@ -490,14 +504,15 @@ def ask(
490504
_error(str(e))
491505
return # unreachable
492506

493-
decision, confidence, rigor, dissent, cost, overview = result
507+
decision, confidence, rigor, dissent, cost, overview, citations = result
494508

495509
from duh.cli.display import ConsensusDisplay
496510

497511
display = ConsensusDisplay()
498512
display.show_final_decision(
499513
decision, confidence, rigor, cost, dissent, overview=overview
500514
)
515+
display.show_citations(citations)
501516

502517

503518
async def _refine_question(question: str, config: DuhConfig) -> str:
@@ -532,7 +547,9 @@ async def _ask_async(
532547
panel: list[str] | None = None,
533548
proposer_override: str | None = None,
534549
challengers_override: list[str] | None = None,
535-
) -> tuple[str, float, float, str | None, float, str | None]:
550+
) -> tuple[
551+
str, float, float, str | None, float, str | None, list[dict[str, str | None]]
552+
]:
536553
"""Async implementation for the ask command."""
537554
from duh.cli.display import ConsensusDisplay
538555

@@ -641,12 +658,19 @@ async def _ask_auto_async(
641658

642659
display = ConsensusDisplay()
643660
display.start()
644-
decision, confidence, rigor, dissent, cost, overview = await _run_consensus(
645-
question, config, pm, display=display
646-
)
661+
(
662+
decision,
663+
confidence,
664+
rigor,
665+
dissent,
666+
cost,
667+
overview,
668+
citations,
669+
) = await _run_consensus(question, config, pm, display=display)
647670
display.show_final_decision(
648671
decision, confidence, rigor, cost, dissent, overview=overview
649672
)
673+
display.show_citations(citations)
650674

651675

652676
async def _ask_decompose_async(
@@ -719,10 +743,11 @@ async def _ask_decompose_async(
719743
# Single-subtask optimization: skip synthesis
720744
if len(subtask_specs) == 1:
721745
result = await _run_consensus(question, config, pm, display=display)
722-
decision, confidence, rigor, dissent, cost, overview = result
746+
decision, confidence, rigor, dissent, cost, overview, citations = result
723747
display.show_final_decision(
724748
decision, confidence, rigor, cost, dissent, overview=overview
725749
)
750+
display.show_citations(citations)
726751
await engine.dispose()
727752
return
728753

@@ -2371,6 +2396,7 @@ async def _batch_async(
23712396
_dissent,
23722397
_cost,
23732398
_overview,
2399+
_citations,
23742400
) = await _run_consensus(question, config, pm)
23752401

23762402
q_cost = pm.total_cost - cost_before

src/duh/cli/display.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,61 @@ def show_tool_use(self, tool_calls_log: list[dict[str, str]]) -> None:
357357
)
358358
)
359359

360+
# ── Citations ──────────────────────────────────────────────
361+
362+
def show_citations(
363+
self,
364+
citations: Sequence[dict[str, str | None]],
365+
) -> None:
366+
"""Display deduplicated citations grouped by hostname."""
367+
if not citations:
368+
return
369+
370+
from urllib.parse import urlparse
371+
372+
# Deduplicate by URL
373+
seen: set[str] = set()
374+
unique: list[dict[str, str | None]] = []
375+
for c in citations:
376+
url = c.get("url") or ""
377+
if url and url not in seen:
378+
seen.add(url)
379+
unique.append(c)
380+
381+
if not unique:
382+
return
383+
384+
# Group by hostname
385+
groups: dict[str, list[dict[str, str | None]]] = {}
386+
for c in unique:
387+
url = c.get("url") or ""
388+
try:
389+
host = urlparse(url).netloc or url
390+
except Exception:
391+
host = url
392+
groups.setdefault(host, []).append(c)
393+
394+
# Sort groups by count descending
395+
sorted_groups = sorted(groups.items(), key=lambda kv: len(kv[1]), reverse=True)
396+
397+
parts: list[str] = []
398+
idx = 1
399+
for host, group in sorted_groups:
400+
for c in group:
401+
title = c.get("title") or host
402+
url = c.get("url") or ""
403+
parts.append(f" [{idx}] {title}\n {url}")
404+
idx += 1
405+
406+
body = "\n".join(parts)
407+
self._console.print(
408+
Panel(
409+
body,
410+
title=f"[bold cyan]Sources[/bold cyan] ({len(unique)})",
411+
border_style="cyan",
412+
)
413+
)
414+
360415
# ── Final output ──────────────────────────────────────────
361416

362417
def show_final_decision(

src/duh/mcp/server.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,15 @@ async def _handle_ask(args: dict) -> list[TextContent]: # type: ignore[type-arg
135135
)
136136
]
137137
else:
138-
decision, confidence, rigor, dissent, cost, _overview = await _run_consensus(
139-
question, config, pm
140-
)
138+
(
139+
decision,
140+
confidence,
141+
rigor,
142+
dissent,
143+
cost,
144+
_overview,
145+
_citations,
146+
) = await _run_consensus(question, config, pm)
141147
return [
142148
TextContent(
143149
type="text",

tests/unit/test_cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def test_displays_decision(
7676
None,
7777
0.0042,
7878
None,
79+
[],
7980
)
8081

8182
result = runner.invoke(cli, ["ask", "What database?"])
@@ -103,6 +104,7 @@ def test_displays_dissent(
103104
"[model-a]: PostgreSQL would be better for scale.",
104105
0.01,
105106
None,
107+
[],
106108
)
107109

108110
result = runner.invoke(cli, ["ask", "What database?"])
@@ -123,7 +125,7 @@ def test_no_dissent_when_none(
123125
from duh.config.schema import DuhConfig
124126

125127
mock_config.return_value = DuhConfig()
126-
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None)
128+
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None, [])
127129

128130
result = runner.invoke(cli, ["ask", "Question?"])
129131

@@ -142,7 +144,7 @@ def test_rounds_option(
142144

143145
config = DuhConfig()
144146
mock_config.return_value = config
145-
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None)
147+
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None, [])
146148

147149
result = runner.invoke(cli, ["ask", "--rounds", "5", "Question?"])
148150

tests/unit/test_cli_batch.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -452,10 +452,18 @@ async def fake_consensus(
452452
pm: Any,
453453
display: Any = None,
454454
tool_registry: Any = None,
455-
) -> tuple[str, float, float, str | None, float, str | None]:
455+
) -> tuple[
456+
str,
457+
float,
458+
float,
459+
str | None,
460+
float,
461+
str | None,
462+
list[dict[str, str | None]],
463+
]:
456464
nonlocal consensus_called
457465
consensus_called = True
458-
return ("Use SQLite.", 0.85, 1.0, None, 0.01, None)
466+
return ("Use SQLite.", 0.85, 1.0, None, 0.01, None, [])
459467

460468
with (
461469
patch("duh.cli.app.load_config", return_value=config),
@@ -547,7 +555,7 @@ async def fake_consensus(
547555
display: Any = None,
548556
tool_registry: Any = None,
549557
) -> tuple[str, float, float, str | None, float, str | None]:
550-
return ("Answer.", 0.9, 1.0, None, 0.01, None)
558+
return ("Answer.", 0.9, 1.0, None, 0.01, None, [])
551559

552560
with (
553561
patch("duh.cli.app.load_config", return_value=config),
@@ -606,7 +614,7 @@ async def fake_consensus(
606614
call_count += 1
607615
if question == "Q2":
608616
raise RuntimeError("Provider timeout")
609-
return ("Answer.", 0.9, 1.0, None, 0.01, None)
617+
return ("Answer.", 0.9, 1.0, None, 0.01, None, [])
610618

611619
with (
612620
patch("duh.cli.app.load_config", return_value=config),
@@ -653,7 +661,7 @@ async def fake_consensus(
653661
) -> tuple[str, float, float, str | None, float, str | None]:
654662
if question == "Q2":
655663
raise RuntimeError("Model unavailable")
656-
return ("Answer.", 0.9, 1.0, None, 0.01, None)
664+
return ("Answer.", 0.9, 1.0, None, 0.01, None, [])
657665

658666
with (
659667
patch("duh.cli.app.load_config", return_value=config),

tests/unit/test_cli_voting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def test_default_protocol_is_consensus(
147147
from duh.config.schema import DuhConfig
148148

149149
mock_config.return_value = DuhConfig()
150-
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None)
150+
mock_run.return_value = ("Answer.", 1.0, 1.0, None, 0.0, None, [])
151151

152152
result = runner.invoke(cli, ["ask", "Question?"])
153153
assert result.exit_code == 0

tests/unit/test_mcp_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ async def test_consensus_protocol(self) -> None:
177177
patch(
178178
"duh.cli.app._run_consensus",
179179
new_callable=AsyncMock,
180-
return_value=("Use SQLite.", 0.9, 1.0, "minor dissent", 0.05, None),
180+
return_value=("Use SQLite.", 0.9, 1.0, "minor dissent", 0.05, None, []),
181181
),
182182
):
183183
result = await _handle_ask({"question": "What DB?", "rounds": 2})

0 commit comments

Comments
 (0)