s-ttp commited on
Commit
59461d8
·
verified ·
1 Parent(s): 83de142

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. app.py +12 -3
  2. requirements.txt +1 -0
  3. src/pdf_report.py +185 -0
  4. src/personas.py +7 -2
  5. src/report.py +9 -3
  6. static/index.html +1 -1
app.py CHANGED
@@ -148,9 +148,18 @@ def api_report(run_id: str):
148
  run = _runs.get(run_id)
149
  if not run or not run.get("result"):
150
  raise HTTPException(404, "No report for this run.")
151
- md = run["result"].get("report_md", "")
152
- return PlainTextResponse(md, media_type="text/markdown", headers={
153
- "Content-Disposition": f'attachment; filename="portfolio-analysis-{run_id}.md"'})
 
 
 
 
 
 
 
 
 
154
 
155
 
156
  @app.get("/api/sample")
 
148
  run = _runs.get(run_id)
149
  if not run or not run.get("result"):
150
  raise HTTPException(404, "No report for this run.")
151
+ from src.pdf_report import build_pdf
152
+ try:
153
+ pdf = build_pdf(run["result"])
154
+ except Exception: # PDF layout must never lose the report — fall back to markdown
155
+ import traceback
156
+ traceback.print_exc()
157
+ md = run["result"].get("report_md", "")
158
+ return PlainTextResponse(md, media_type="text/markdown", headers={
159
+ "Content-Disposition": f'attachment; filename="conclave-verdict-{run_id}.md"'})
160
+ from fastapi.responses import Response
161
+ return Response(pdf, media_type="application/pdf", headers={
162
+ "Content-Disposition": f'attachment; filename="conclave-verdict-{run_id}.pdf"'})
163
 
164
 
165
  @app.get("/api/sample")
requirements.txt CHANGED
@@ -7,3 +7,4 @@ yfinance>=1.4
7
  huggingface_hub>=0.24
8
  pyarrow>=16.0
9
  certifi
 
 
7
  huggingface_hub>=0.24
8
  pyarrow>=16.0
9
  certifi
10
+ reportlab>=4.0
src/pdf_report.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conclave PDF report — typeset with reportlab (crucible's report stack, simplified).
2
+ Built directly from the result payload; tolerates missing sections (no LLM, no target).
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import io
7
+
8
+ from reportlab.lib import colors
9
+ from reportlab.lib.pagesizes import A4
10
+ from reportlab.lib.styles import ParagraphStyle
11
+ from reportlab.lib.units import mm
12
+ from reportlab.platypus import (Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle)
13
+
14
+ INK = colors.HexColor("#141b18")
15
+ BRASS = colors.HexColor("#8a6d14")
16
+ DIM = colors.HexColor("#5a655e")
17
+ GOOD = colors.HexColor("#1e7a4a")
18
+ WARN = colors.HexColor("#9a6b12")
19
+ BAD = colors.HexColor("#a33a40")
20
+ LINE = colors.HexColor("#d8d4c6")
21
+
22
+ _ACTION_COLOR = {"Buy More": GOOD, "Keep": DIM, "Trim": WARN, "Sell": BAD}
23
+ _SEV_COLOR = {"good": GOOD, "warn": WARN, "bad": BAD, "OK": GOOD, "WATCH": WARN, "ALERT": BAD}
24
+
25
+
26
+ def _sty(name, **kw):
27
+ base = dict(fontName="Helvetica", fontSize=9.5, leading=13, textColor=INK)
28
+ base.update(kw)
29
+ return ParagraphStyle(name, **base) # type: ignore[arg-type]
30
+
31
+
32
+ S_TITLE = _sty("t", fontName="Times-Bold", fontSize=24, leading=28)
33
+ S_TAG = _sty("tag", fontName="Times-Italic", fontSize=11, textColor=BRASS)
34
+ S_H2 = _sty("h2", fontName="Helvetica-Bold", fontSize=11, leading=15, textColor=BRASS,
35
+ spaceBefore=14, spaceAfter=5)
36
+ S_BODY = _sty("b")
37
+ S_VERDICT = _sty("v", fontName="Times-Roman", fontSize=12.5, leading=17)
38
+ S_DIM = _sty("d", fontSize=8, textColor=DIM)
39
+ S_CELL = _sty("c", fontSize=8.5, leading=11)
40
+
41
+
42
+ _UNI = {"—": "-", "–": "-", "→": "->", "←": "<-", "≈": "~", "≥": ">=", "≤": "<=",
43
+ "↑": "up", "↓": "down", "•": "-", "‘": "'", "’": "'", "“": '"', "”": '"',
44
+ "₹": "Rs ", "…": "...", "⚠": "!", "✓": "OK", "✗": "x"}
45
+
46
+
47
+ def _txt(x) -> str:
48
+ s = str(x if x is not None else "")
49
+ for u, a in _UNI.items():
50
+ s = s.replace(u, a)
51
+ s = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
52
+ return s.encode("latin-1", "replace").decode("latin-1") # Helvetica-safe
53
+
54
+
55
+ def _pct(x, signed=True):
56
+ if not isinstance(x, (int, float)):
57
+ return "-"
58
+ return f"{x * 100:+.1f}%" if signed else f"{x * 100:.1f}%"
59
+
60
+
61
+ def _table(data, widths, header=True, aligns=None):
62
+ t = Table(data, colWidths=widths, repeatRows=1 if header else 0)
63
+ style = [("VALIGN", (0, 0), (-1, -1), "TOP"),
64
+ ("LINEBELOW", (0, 0), (-1, -2), 0.4, LINE),
65
+ ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]
66
+ if header:
67
+ style += [("FONT", (0, 0), (-1, 0), "Helvetica-Bold", 8),
68
+ ("TEXTCOLOR", (0, 0), (-1, 0), DIM),
69
+ ("LINEBELOW", (0, 0), (-1, 0), 0.8, BRASS)]
70
+ for i, a in enumerate(aligns or []):
71
+ style.append(("ALIGN", (i, 0), (i, -1), a))
72
+ t.setStyle(TableStyle(style))
73
+ return t
74
+
75
+
76
+ def build_pdf(res: dict) -> bytes:
77
+ buf = io.BytesIO()
78
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=16 * mm, rightMargin=16 * mm,
79
+ topMargin=16 * mm, bottomMargin=16 * mm, title="Conclave — Portfolio Verdict")
80
+ E = []
81
+ E.append(Paragraph("Conclave", S_TITLE))
82
+ E.append(Paragraph("Five analysts. One verdict.", S_TAG))
83
+ meta = f"Generated {_txt(res.get('generated_at'))}"
84
+ ctx = res.get("context") or {}
85
+ if ctx:
86
+ meta += " · Context: " + ", ".join(f"{k}: {v}" for k, v in ctx.items())
87
+ E.append(Paragraph(_txt(meta), S_DIM))
88
+ E.append(Spacer(1, 8))
89
+
90
+ E.append(Paragraph(f"Portfolio health: <b>{res.get('health_score', '?')}/100</b>",
91
+ _sty("hs", fontName="Helvetica-Bold", fontSize=13)))
92
+ E.append(Spacer(1, 3))
93
+ E.append(Paragraph(_txt(res.get("verdict", "")), S_VERDICT))
94
+
95
+ E.append(Paragraph("KEY FINDINGS", S_H2))
96
+ for h in res.get("highlights", []):
97
+ c = _SEV_COLOR.get(str(h.get("severity")), DIM)
98
+ E.append(Paragraph(
99
+ f'<font color="#{c.hexval()[2:]}">&#9632;</font>&nbsp; <b>{_txt(h.get("title"))}</b> — '
100
+ f'{_txt(h.get("detail"))} <font size="7" color="#{DIM.hexval()[2:]}">({_txt(h.get("lens", "portfolio"))} lens)</font>',
101
+ S_BODY))
102
+ E.append(Spacer(1, 3))
103
+
104
+ E.append(Paragraph("RECOMMENDATIONS", S_H2))
105
+ rows: list[list] = [["Holding", "Weight", "P/L", "vs intrinsic", "Action", "Why"]]
106
+ for r in res.get("holdings", []):
107
+ c = _ACTION_COLOR.get(r.get("action"), DIM)
108
+ rows.append([
109
+ Paragraph(f"<b>{_txt(r['ticker'])}</b><br/><font size='7' color='#{DIM.hexval()[2:]}'>{_txt(r.get('name'))}</font>", S_CELL),
110
+ _pct(r.get("weight"), signed=False), _pct(r.get("pl_pct")), _pct(r.get("upside")),
111
+ Paragraph(f"<b><font color='#{c.hexval()[2:]}'>{_txt(r.get('action'))}</font></b>", S_CELL),
112
+ Paragraph(_txt(r.get("rationale")), S_CELL)])
113
+ E.append(_table(rows, [28 * mm, 15 * mm, 16 * mm, 18 * mm, 16 * mm, 84 * mm],
114
+ aligns=["LEFT", "RIGHT", "RIGHT", "RIGHT", "LEFT", "LEFT"]))
115
+
116
+ E.append(Paragraph("PORTFOLIO REVIEW", S_H2))
117
+ st = res.get("strategist") or {}
118
+ if st.get("strategy_read"):
119
+ E.append(Paragraph(f"<b>Strategy read:</b> {_txt(st['strategy_read'])}", S_BODY))
120
+ if st.get("style_tilt"):
121
+ E.append(Paragraph(f"<b>Style tilt:</b> {_txt(st['style_tilt'])}", S_BODY))
122
+ if st.get("gaps"):
123
+ E.append(Paragraph("<b>Missing exposures:</b> " + _txt(", ".join(map(str, st["gaps"]))), S_BODY))
124
+ E.append(Spacer(1, 5))
125
+ for c0 in res.get("risk_scorecard", []):
126
+ c = _SEV_COLOR.get(str(c0.get("status")), DIM)
127
+ E.append(Paragraph(
128
+ f"<font color='#{c.hexval()[2:]}'><b>[{_txt(c0.get('status'))}]</b></font> "
129
+ f"<b>{_txt(c0.get('check'))}</b> — {_txt(c0.get('detail'))}", S_BODY))
130
+ E.append(Spacer(1, 2))
131
+ sa = res.get("sector_allocation") or {}
132
+ if sa:
133
+ E.append(Spacer(1, 4))
134
+ E.append(Paragraph("<b>Sector mix:</b> " + _txt(" · ".join(
135
+ f"{s} {w * 100:.0f}%" for s, w in sa.items())), S_BODY))
136
+
137
+ alts = res.get("alternatives") or []
138
+ if alts:
139
+ E.append(Paragraph("WORTH A LOOK — GAP FILLERS", S_H2))
140
+ for a in alts:
141
+ sig = " · ".join(a.get("signals") or [])
142
+ E.append(Paragraph(
143
+ f"<b>{_txt(a.get('ticker'))}</b> — {_txt(a.get('reason'))}"
144
+ + (f" <font size='7' color='#{DIM.hexval()[2:]}'>[{_txt(sig)}]</font>" if sig else ""),
145
+ S_BODY))
146
+ E.append(Spacer(1, 3))
147
+
148
+ target = res.get("target_portfolio") or []
149
+ if target:
150
+ E.append(Paragraph("RECOMMENDED TARGET PORTFOLIO", S_H2))
151
+ E.append(Paragraph("Committee conviction sizes positions under risk-profile caps. "
152
+ "Educational illustration, not advice.", S_DIM))
153
+ E.append(Spacer(1, 4))
154
+ rows = [["Holding", "Sector", "Current", "Target", "Change"]]
155
+ for r in target:
156
+ name = r["ticker"] + (" (new)" if r.get("is_new") else "")
157
+ rows.append([_txt(name), _txt(r.get("sector")), _pct(r["current_w"], signed=False),
158
+ _pct(r["target_w"], signed=False), f"{r['delta'] * 100:+.1f}pp"])
159
+ E.append(_table(rows, [38 * mm, 55 * mm, 22 * mm, 22 * mm, 22 * mm],
160
+ aligns=["LEFT", "LEFT", "RIGHT", "RIGHT", "RIGHT"]))
161
+
162
+ if res.get("llm_used"):
163
+ E.append(Paragraph("APPENDIX — ANALYST COMMITTEE DETAIL", S_H2))
164
+ for r in res.get("holdings", []):
165
+ E.append(Paragraph(
166
+ f"<b>{_txt(r['ticker'])}</b> · {_txt(r.get('action'))} · committee conviction "
167
+ f"{r.get('conviction')}/100 across {r.get('n_lenses')} lenses"
168
+ + (f" · intrinsic {_txt(r['intrinsic'])} ({_pct(r.get('upside'))} vs price, {_txt(r.get('lifecycle'))})"
169
+ if r.get("intrinsic") else ""), S_BODY))
170
+ scores = r.get("lens_scores") or {}
171
+ if scores:
172
+ E.append(Paragraph("Lens scores: " + _txt(" · ".join(
173
+ f"{k} {v:.0f}" for k, v in scores.items())), S_DIM))
174
+ for lab, key in (("Strengths", "strengths"), ("Risks", "risks")):
175
+ items = r.get(key) or []
176
+ if items:
177
+ E.append(Paragraph(f"<i>{lab}:</i> " + _txt(" • ".join(items)), S_DIM))
178
+ if r.get("flags"):
179
+ E.append(Paragraph("Guards: " + _txt(", ".join(r["flags"])), S_DIM))
180
+ E.append(Spacer(1, 6))
181
+
182
+ E.append(Spacer(1, 10))
183
+ E.append(Paragraph(_txt(res.get("disclaimer", "")), S_DIM))
184
+ doc.build(E)
185
+ return buf.getvalue()
src/personas.py CHANGED
@@ -132,8 +132,13 @@ _CIO_SYSTEM = (
132
  "1) verdict: ONE sentence describing the portfolio (its character + its biggest issue).\n"
133
  "2) highlights: the 3-5 most decision-relevant findings across stock and portfolio level, "
134
  "each with the number that proves it and which lens surfaced it.\n"
135
- "3) rationale: for EACH holding, ONE plain-English sentence justifying its given action, "
136
- "reflecting the committee's consensus or naming the dissent.\n"
 
 
 
 
 
137
  "4) alternatives: pick 2-4 tickers ONLY from the provided candidate list that best fill the "
138
  "strategist's gaps and complement the existing holdings; each candidate carries its backing "
139
  "signals (quant factor rank and/or smart-money 13F+insider accumulation) — cite the signal in "
 
132
  "1) verdict: ONE sentence describing the portfolio (its character + its biggest issue).\n"
133
  "2) highlights: the 3-5 most decision-relevant findings across stock and portfolio level, "
134
  "each with the number that proves it and which lens surfaced it.\n"
135
+ "3) rationale: for EACH holding, ONE short sentence a non-finance friend would instantly "
136
+ "understand, justifying its given action. NO jargon and NO scores: never mention conviction "
137
+ "numbers, EV/EBITDA, PEG, beta, percentiles, moving averages or 'the committee' (the detail "
138
+ "view already shows all that). Translate the data into everyday meaning — e.g. 'Profits fell "
139
+ "sharply last year and this one stock is nearly half your portfolio' or 'The business keeps "
140
+ "growing strongly and the price is still reasonable for it'. At most one simple concrete "
141
+ "fact per sentence.\n"
142
  "4) alternatives: pick 2-4 tickers ONLY from the provided candidate list that best fill the "
143
  "strategist's gaps and complement the existing holdings; each candidate carries its backing "
144
  "signals (quant factor rank and/or smart-money 13F+insider accumulation) — cite the signal in "
src/report.py CHANGED
@@ -89,10 +89,16 @@ def _row_fallback(dec, dcf) -> str:
89
  if not dec["scores"]:
90
  up = dcf.get("upside")
91
  if isinstance(up, (int, float)):
92
- side = "below" if up > 0 else "above"
93
- return f"Quant view only (no AI panel): trading {abs(up) * 100:.0f}% {side} estimated intrinsic value."
 
94
  return "Quant view only — no AI panel available for this run."
95
- return f"Committee conviction {dec['conviction']}/100 across {dec['n_lenses']} lenses."
 
 
 
 
 
96
 
97
 
98
  def _pct(x):
 
89
  if not dec["scores"]:
90
  up = dcf.get("upside")
91
  if isinstance(up, (int, float)):
92
+ side = "less" if up > 0 else "more"
93
+ return (f"The market is paying {abs(up) * 100:.0f}% {side} than our estimate of "
94
+ f"what the business is worth.")
95
  return "Quant view only — no AI panel available for this run."
96
+ c = dec["conviction"]
97
+ mood = ("the analysts see serious problems here" if c < 35 else
98
+ "the analysts found more concerns than positives" if c < 45 else
99
+ "the analysts' views are balanced on this one" if c <= 62 else
100
+ "the analysts see clear positives outweighing the risks")
101
+ return f"On balance, {mood} — open the row for the detail."
102
 
103
 
104
  def _pct(x):
static/index.html CHANGED
@@ -166,7 +166,7 @@
166
 
167
  <footer class="card footer">
168
  <div class="footer-actions">
169
- <a id="download" class="primary" href="#" download>Download full report</a>
170
  <button class="ghost" id="rerun">↺ New analysis</button>
171
  </div>
172
  <div id="meta" class="meta"></div>
 
166
 
167
  <footer class="card footer">
168
  <div class="footer-actions">
169
+ <a id="download" class="primary" href="#" download>Download full report (PDF)</a>
170
  <button class="ghost" id="rerun">↺ New analysis</button>
171
  </div>
172
  <div id="meta" class="meta"></div>