qualia-framework 2.4.9 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.5.1
|
|
Binary file
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Qualia Report — DOCX Generator
|
|
4
|
+
|
|
5
|
+
Reads JSON report data from stdin, produces a branded DOCX file.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
echo '{"project": "...", ...}' | python3 generate-report-docx.py /path/to/output.docx
|
|
9
|
+
|
|
10
|
+
Input JSON schema:
|
|
11
|
+
{
|
|
12
|
+
"project": "project-name",
|
|
13
|
+
"user": "Fawzi Goussous",
|
|
14
|
+
"date": "2026-04-06",
|
|
15
|
+
"time": "14:30",
|
|
16
|
+
"branch": "main",
|
|
17
|
+
"duration": "~3 hours",
|
|
18
|
+
"phase": "Phase 3 of 6 — Auth Integration",
|
|
19
|
+
"done": [...],
|
|
20
|
+
"deviations": [...],
|
|
21
|
+
"blockers": [...],
|
|
22
|
+
"next": [...],
|
|
23
|
+
"commits": [...]
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
import os
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
from docx import Document
|
|
33
|
+
from docx.shared import Inches, Pt, Cm, RGBColor, Emu
|
|
34
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
35
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
36
|
+
from docx.oxml.ns import qn, nsdecls
|
|
37
|
+
from docx.oxml import parse_xml
|
|
38
|
+
|
|
39
|
+
# ─── Brand constants ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
TEAL = RGBColor(0x2A, 0xBF, 0xBF)
|
|
42
|
+
TEAL_DARK = RGBColor(0x1E, 0x96, 0x96)
|
|
43
|
+
DARK = RGBColor(0x1A, 0x1A, 0x2E)
|
|
44
|
+
CHARCOAL = RGBColor(0x2D, 0x2D, 0x3F)
|
|
45
|
+
GRAY = RGBColor(0x6B, 0x7B, 0x8D)
|
|
46
|
+
LIGHT_GRAY = RGBColor(0x9A, 0xA5, 0xB0)
|
|
47
|
+
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
|
48
|
+
|
|
49
|
+
TEAL_HEX = "2ABFBF"
|
|
50
|
+
TEAL_DARK_HEX = "1E9696"
|
|
51
|
+
DARK_HEX = "1A1A2E"
|
|
52
|
+
INFO_BG = "F0FAFA"
|
|
53
|
+
COMMIT_BG = "F5F7F9"
|
|
54
|
+
|
|
55
|
+
LOGO_PATH = os.path.join(os.path.dirname(__file__), "..", "assets", "qualia-logo.png")
|
|
56
|
+
|
|
57
|
+
FONT_BODY = "Segoe UI"
|
|
58
|
+
FONT_HEADING = "Segoe UI Semibold"
|
|
59
|
+
FONT_MONO = "Cascadia Code"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def remove_table_borders(table):
|
|
65
|
+
"""Remove all borders from a table."""
|
|
66
|
+
tbl = table._tbl
|
|
67
|
+
tblPr = tbl.tblPr if tbl.tblPr is not None else parse_xml(f'<w:tblPr {nsdecls("w")}/>')
|
|
68
|
+
borders = parse_xml(
|
|
69
|
+
f'<w:tblBorders {nsdecls("w")}>'
|
|
70
|
+
f' <w:top w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
71
|
+
f' <w:left w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
72
|
+
f' <w:bottom w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
73
|
+
f' <w:right w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
74
|
+
f' <w:insideH w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
75
|
+
f' <w:insideV w:val="none" w:sz="0" w:space="0" w:color="auto"/>'
|
|
76
|
+
f'</w:tblBorders>'
|
|
77
|
+
)
|
|
78
|
+
tblPr.append(borders)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_cell_shading(cell, color_hex):
|
|
82
|
+
"""Set background color on a table cell."""
|
|
83
|
+
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color_hex}"/>')
|
|
84
|
+
cell._tc.get_or_add_tcPr().append(shading)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def set_cell_margins(cell, top=0, bottom=0, left=100, right=100):
|
|
88
|
+
"""Set cell margins in twips."""
|
|
89
|
+
tc = cell._tc
|
|
90
|
+
tcPr = tc.get_or_add_tcPr()
|
|
91
|
+
tcMar = parse_xml(
|
|
92
|
+
f'<w:tcMar {nsdecls("w")}>'
|
|
93
|
+
f' <w:top w:w="{top}" w:type="dxa"/>'
|
|
94
|
+
f' <w:bottom w:w="{bottom}" w:type="dxa"/>'
|
|
95
|
+
f' <w:start w:w="{left}" w:type="dxa"/>'
|
|
96
|
+
f' <w:end w:w="{right}" w:type="dxa"/>'
|
|
97
|
+
f'</w:tcMar>'
|
|
98
|
+
)
|
|
99
|
+
tcMar_old = tcPr.find(qn('w:tcMar'))
|
|
100
|
+
if tcMar_old is not None:
|
|
101
|
+
tcPr.remove(tcMar_old)
|
|
102
|
+
tcPr.append(tcMar)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def add_teal_rule(doc, weight=6):
|
|
106
|
+
"""Add a teal horizontal line."""
|
|
107
|
+
p = doc.add_paragraph()
|
|
108
|
+
p.paragraph_format.space_before = Pt(2)
|
|
109
|
+
p.paragraph_format.space_after = Pt(8)
|
|
110
|
+
pPr = p._p.get_or_add_pPr()
|
|
111
|
+
pBdr = parse_xml(
|
|
112
|
+
f'<w:pBdr {nsdecls("w")}>'
|
|
113
|
+
f' <w:bottom w:val="single" w:sz="{weight}" w:space="1" w:color="{TEAL_HEX}"/>'
|
|
114
|
+
f'</w:pBdr>'
|
|
115
|
+
)
|
|
116
|
+
pPr.append(pBdr)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def add_section_heading(doc, text):
|
|
120
|
+
"""Add a premium section heading with teal left accent."""
|
|
121
|
+
p = doc.add_paragraph()
|
|
122
|
+
p.paragraph_format.space_before = Pt(20)
|
|
123
|
+
p.paragraph_format.space_after = Pt(10)
|
|
124
|
+
# Left border accent
|
|
125
|
+
pPr = p._p.get_or_add_pPr()
|
|
126
|
+
pBdr = parse_xml(
|
|
127
|
+
f'<w:pBdr {nsdecls("w")}>'
|
|
128
|
+
f' <w:left w:val="single" w:sz="18" w:space="8" w:color="{TEAL_HEX}"/>'
|
|
129
|
+
f' <w:bottom w:val="single" w:sz="2" w:space="4" w:color="E0E0E0"/>'
|
|
130
|
+
f'</w:pBdr>'
|
|
131
|
+
)
|
|
132
|
+
pPr.append(pBdr)
|
|
133
|
+
run = p.add_run(text)
|
|
134
|
+
run.font.name = FONT_HEADING
|
|
135
|
+
run.font.size = Pt(13)
|
|
136
|
+
run.font.color.rgb = CHARCOAL
|
|
137
|
+
run.font.bold = True
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def add_bullet(doc, text):
|
|
142
|
+
"""Add a styled bullet point with teal bullet."""
|
|
143
|
+
p = doc.add_paragraph()
|
|
144
|
+
p.paragraph_format.space_after = Pt(5)
|
|
145
|
+
p.paragraph_format.left_indent = Cm(0.8)
|
|
146
|
+
# Teal bullet
|
|
147
|
+
bullet_run = p.add_run(" \u25B8 ")
|
|
148
|
+
bullet_run.font.name = FONT_BODY
|
|
149
|
+
bullet_run.font.size = Pt(10)
|
|
150
|
+
bullet_run.font.color.rgb = TEAL
|
|
151
|
+
# Text
|
|
152
|
+
text_run = p.add_run(text)
|
|
153
|
+
text_run.font.name = FONT_BODY
|
|
154
|
+
text_run.font.size = Pt(10.5)
|
|
155
|
+
text_run.font.color.rgb = DARK
|
|
156
|
+
return p
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def add_spacer(doc, pts=6):
|
|
160
|
+
"""Add vertical spacing."""
|
|
161
|
+
p = doc.add_paragraph()
|
|
162
|
+
p.paragraph_format.space_before = Pt(0)
|
|
163
|
+
p.paragraph_format.space_after = Pt(pts)
|
|
164
|
+
run = p.add_run("")
|
|
165
|
+
run.font.size = Pt(2)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ─── Document builder ────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def build_report(data, output_path):
|
|
171
|
+
doc = Document()
|
|
172
|
+
|
|
173
|
+
# ── Page setup ──
|
|
174
|
+
for section in doc.sections:
|
|
175
|
+
section.top_margin = Cm(1.8)
|
|
176
|
+
section.bottom_margin = Cm(1.5)
|
|
177
|
+
section.left_margin = Cm(2.5)
|
|
178
|
+
section.right_margin = Cm(2.5)
|
|
179
|
+
|
|
180
|
+
# ── Page border ──
|
|
181
|
+
for section in doc.sections:
|
|
182
|
+
sectPr = section._sectPr
|
|
183
|
+
pgBorders = parse_xml(
|
|
184
|
+
f'<w:pgBorders {nsdecls("w")} w:offsetFrom="page">'
|
|
185
|
+
f' <w:top w:val="single" w:sz="4" w:space="24" w:color="{TEAL_HEX}"/>'
|
|
186
|
+
f' <w:left w:val="single" w:sz="4" w:space="24" w:color="{TEAL_HEX}"/>'
|
|
187
|
+
f' <w:bottom w:val="single" w:sz="4" w:space="24" w:color="{TEAL_HEX}"/>'
|
|
188
|
+
f' <w:right w:val="single" w:sz="4" w:space="24" w:color="{TEAL_HEX}"/>'
|
|
189
|
+
f'</w:pgBorders>'
|
|
190
|
+
)
|
|
191
|
+
sectPr.append(pgBorders)
|
|
192
|
+
|
|
193
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
194
|
+
# HEADER — Logo left, Title + subtitle right
|
|
195
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
196
|
+
|
|
197
|
+
header_table = doc.add_table(rows=1, cols=2)
|
|
198
|
+
header_table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
199
|
+
header_table.autofit = True
|
|
200
|
+
remove_table_borders(header_table)
|
|
201
|
+
|
|
202
|
+
# Set column widths
|
|
203
|
+
header_table.columns[0].width = Cm(4)
|
|
204
|
+
header_table.columns[1].width = Cm(12)
|
|
205
|
+
|
|
206
|
+
# Logo
|
|
207
|
+
logo_cell = header_table.cell(0, 0)
|
|
208
|
+
logo_p = logo_cell.paragraphs[0]
|
|
209
|
+
logo_p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
210
|
+
if os.path.exists(LOGO_PATH):
|
|
211
|
+
logo_p.add_run().add_picture(LOGO_PATH, width=Cm(2.5))
|
|
212
|
+
|
|
213
|
+
# Title block
|
|
214
|
+
title_cell = header_table.cell(0, 1)
|
|
215
|
+
# "Session Report" — large teal
|
|
216
|
+
title_p = title_cell.paragraphs[0]
|
|
217
|
+
title_p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
218
|
+
run = title_p.add_run("Session Report")
|
|
219
|
+
run.font.name = FONT_HEADING
|
|
220
|
+
run.font.size = Pt(26)
|
|
221
|
+
run.font.color.rgb = TEAL
|
|
222
|
+
run.font.bold = True
|
|
223
|
+
title_p.paragraph_format.space_after = Pt(2)
|
|
224
|
+
|
|
225
|
+
# "QUALIA SOLUTIONS" — subtle uppercase
|
|
226
|
+
sub_p = title_cell.add_paragraph()
|
|
227
|
+
sub_p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
228
|
+
sub_p.paragraph_format.space_after = Pt(0)
|
|
229
|
+
run = sub_p.add_run("QUALIA SOLUTIONS")
|
|
230
|
+
run.font.name = FONT_BODY
|
|
231
|
+
run.font.size = Pt(9)
|
|
232
|
+
run.font.color.rgb = LIGHT_GRAY
|
|
233
|
+
run.font.bold = True
|
|
234
|
+
# Letter spacing
|
|
235
|
+
rPr = run._r.get_or_add_rPr()
|
|
236
|
+
spacing = parse_xml(f'<w:spacing {nsdecls("w")} w:val="60"/>')
|
|
237
|
+
rPr.append(spacing)
|
|
238
|
+
|
|
239
|
+
add_teal_rule(doc, weight=12)
|
|
240
|
+
|
|
241
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
242
|
+
# INFO CARD — teal-tinted background, 2-column layout
|
|
243
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
244
|
+
|
|
245
|
+
report_date = data.get("date", datetime.now().strftime("%Y-%m-%d"))
|
|
246
|
+
report_time = data.get("time", datetime.now().strftime("%H:%M"))
|
|
247
|
+
user_name = data.get("user", "—")
|
|
248
|
+
|
|
249
|
+
info_left = [
|
|
250
|
+
("Project", data.get("project", "—")),
|
|
251
|
+
("Prepared by", user_name),
|
|
252
|
+
]
|
|
253
|
+
if data.get("phase"):
|
|
254
|
+
info_left.append(("Phase", data["phase"]))
|
|
255
|
+
|
|
256
|
+
info_right = [
|
|
257
|
+
("Date", report_date),
|
|
258
|
+
("Time", report_time),
|
|
259
|
+
("Branch", data.get("branch", "—")),
|
|
260
|
+
("Duration", data.get("duration", "—")),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
max_rows = max(len(info_left), len(info_right))
|
|
264
|
+
info_table = doc.add_table(rows=max_rows, cols=4)
|
|
265
|
+
info_table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
266
|
+
remove_table_borders(info_table)
|
|
267
|
+
|
|
268
|
+
# Shade entire info card
|
|
269
|
+
for row in info_table.rows:
|
|
270
|
+
for cell in row.cells:
|
|
271
|
+
set_cell_shading(cell, INFO_BG)
|
|
272
|
+
set_cell_margins(cell, top=40, bottom=40, left=120, right=60)
|
|
273
|
+
|
|
274
|
+
# Fill left columns
|
|
275
|
+
for i, (label, value) in enumerate(info_left):
|
|
276
|
+
# Label
|
|
277
|
+
cell_l = info_table.cell(i, 0)
|
|
278
|
+
cell_l.width = Cm(2.8)
|
|
279
|
+
p = cell_l.paragraphs[0]
|
|
280
|
+
p.paragraph_format.space_after = Pt(1)
|
|
281
|
+
run = p.add_run(label)
|
|
282
|
+
run.font.name = FONT_BODY
|
|
283
|
+
run.font.size = Pt(9)
|
|
284
|
+
run.font.color.rgb = GRAY
|
|
285
|
+
run.font.bold = True
|
|
286
|
+
# Value
|
|
287
|
+
cell_v = info_table.cell(i, 1)
|
|
288
|
+
cell_v.width = Cm(5)
|
|
289
|
+
p = cell_v.paragraphs[0]
|
|
290
|
+
p.paragraph_format.space_after = Pt(1)
|
|
291
|
+
run = p.add_run(value)
|
|
292
|
+
run.font.name = FONT_BODY
|
|
293
|
+
run.font.size = Pt(10)
|
|
294
|
+
run.font.color.rgb = DARK
|
|
295
|
+
run.font.bold = True if label == "Project" else False
|
|
296
|
+
|
|
297
|
+
# Fill right columns
|
|
298
|
+
for i, (label, value) in enumerate(info_right):
|
|
299
|
+
cell_l = info_table.cell(i, 2)
|
|
300
|
+
cell_l.width = Cm(2.2)
|
|
301
|
+
p = cell_l.paragraphs[0]
|
|
302
|
+
p.paragraph_format.space_after = Pt(1)
|
|
303
|
+
run = p.add_run(label)
|
|
304
|
+
run.font.name = FONT_BODY
|
|
305
|
+
run.font.size = Pt(9)
|
|
306
|
+
run.font.color.rgb = GRAY
|
|
307
|
+
run.font.bold = True
|
|
308
|
+
cell_v = info_table.cell(i, 3)
|
|
309
|
+
cell_v.width = Cm(4)
|
|
310
|
+
p = cell_v.paragraphs[0]
|
|
311
|
+
p.paragraph_format.space_after = Pt(1)
|
|
312
|
+
run = p.add_run(value)
|
|
313
|
+
run.font.name = FONT_BODY
|
|
314
|
+
run.font.size = Pt(10)
|
|
315
|
+
run.font.color.rgb = DARK
|
|
316
|
+
|
|
317
|
+
add_spacer(doc, 4)
|
|
318
|
+
|
|
319
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
320
|
+
# BODY SECTIONS
|
|
321
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
322
|
+
|
|
323
|
+
# ── What Was Done ──
|
|
324
|
+
done_items = data.get("done", [])
|
|
325
|
+
if done_items:
|
|
326
|
+
add_section_heading(doc, "What Was Done")
|
|
327
|
+
for item in done_items:
|
|
328
|
+
add_bullet(doc, item)
|
|
329
|
+
|
|
330
|
+
# ── Deviations (only if any) ──
|
|
331
|
+
deviations = data.get("deviations", [])
|
|
332
|
+
if deviations:
|
|
333
|
+
add_section_heading(doc, "Deviations")
|
|
334
|
+
for item in deviations:
|
|
335
|
+
add_bullet(doc, item)
|
|
336
|
+
|
|
337
|
+
# ── Blockers (only if any) ──
|
|
338
|
+
blockers = data.get("blockers", [])
|
|
339
|
+
if blockers:
|
|
340
|
+
add_section_heading(doc, "Blockers")
|
|
341
|
+
for item in blockers:
|
|
342
|
+
add_bullet(doc, item)
|
|
343
|
+
|
|
344
|
+
# ── Commits (compact card) ──
|
|
345
|
+
commits = data.get("commits", [])
|
|
346
|
+
if commits:
|
|
347
|
+
add_section_heading(doc, "Commits")
|
|
348
|
+
# Render in a shaded card
|
|
349
|
+
commit_table = doc.add_table(rows=1, cols=1)
|
|
350
|
+
commit_table.alignment = WD_TABLE_ALIGNMENT.LEFT
|
|
351
|
+
remove_table_borders(commit_table)
|
|
352
|
+
commit_cell = commit_table.cell(0, 0)
|
|
353
|
+
set_cell_shading(commit_cell, COMMIT_BG)
|
|
354
|
+
set_cell_margins(commit_cell, top=80, bottom=80, left=160, right=160)
|
|
355
|
+
# First commit in existing paragraph
|
|
356
|
+
p = commit_cell.paragraphs[0]
|
|
357
|
+
p.paragraph_format.space_after = Pt(2)
|
|
358
|
+
run = p.add_run(commits[0])
|
|
359
|
+
run.font.name = FONT_MONO
|
|
360
|
+
run.font.size = Pt(8.5)
|
|
361
|
+
run.font.color.rgb = GRAY
|
|
362
|
+
# Remaining commits
|
|
363
|
+
for c in commits[1:10]:
|
|
364
|
+
p = commit_cell.add_paragraph()
|
|
365
|
+
p.paragraph_format.space_after = Pt(2)
|
|
366
|
+
run = p.add_run(c)
|
|
367
|
+
run.font.name = FONT_MONO
|
|
368
|
+
run.font.size = Pt(8.5)
|
|
369
|
+
run.font.color.rgb = GRAY
|
|
370
|
+
if len(commits) > 10:
|
|
371
|
+
p = commit_cell.add_paragraph()
|
|
372
|
+
run = p.add_run(f" ... and {len(commits) - 10} more")
|
|
373
|
+
run.font.name = FONT_BODY
|
|
374
|
+
run.font.size = Pt(8)
|
|
375
|
+
run.font.color.rgb = LIGHT_GRAY
|
|
376
|
+
run.font.italic = True
|
|
377
|
+
|
|
378
|
+
# ── Next Steps ──
|
|
379
|
+
next_items = data.get("next", [])
|
|
380
|
+
if next_items:
|
|
381
|
+
add_section_heading(doc, "Next Steps")
|
|
382
|
+
for item in next_items:
|
|
383
|
+
add_bullet(doc, item)
|
|
384
|
+
|
|
385
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
386
|
+
# FOOTER
|
|
387
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
388
|
+
|
|
389
|
+
add_spacer(doc, 16)
|
|
390
|
+
add_teal_rule(doc, weight=4)
|
|
391
|
+
|
|
392
|
+
footer_p = doc.add_paragraph()
|
|
393
|
+
footer_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
394
|
+
footer_p.paragraph_format.space_after = Pt(0)
|
|
395
|
+
|
|
396
|
+
run = footer_p.add_run("Qualia Solutions")
|
|
397
|
+
run.font.name = FONT_HEADING
|
|
398
|
+
run.font.size = Pt(8)
|
|
399
|
+
run.font.color.rgb = TEAL
|
|
400
|
+
run.font.bold = True
|
|
401
|
+
|
|
402
|
+
run = footer_p.add_run(f" \u00B7 {report_date} {report_time}")
|
|
403
|
+
run.font.name = FONT_BODY
|
|
404
|
+
run.font.size = Pt(8)
|
|
405
|
+
run.font.color.rgb = LIGHT_GRAY
|
|
406
|
+
|
|
407
|
+
tagline_p = doc.add_paragraph()
|
|
408
|
+
tagline_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
409
|
+
run = tagline_p.add_run("qualia.solutions")
|
|
410
|
+
run.font.name = FONT_BODY
|
|
411
|
+
run.font.size = Pt(7)
|
|
412
|
+
run.font.color.rgb = LIGHT_GRAY
|
|
413
|
+
|
|
414
|
+
# ── Save ──
|
|
415
|
+
doc.save(output_path)
|
|
416
|
+
return output_path
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
if __name__ == "__main__":
|
|
422
|
+
if len(sys.argv) < 2:
|
|
423
|
+
print("Usage: echo '{...}' | python3 generate-report-docx.py /path/to/output.docx", file=sys.stderr)
|
|
424
|
+
sys.exit(1)
|
|
425
|
+
|
|
426
|
+
output_path = sys.argv[1]
|
|
427
|
+
data = json.load(sys.stdin)
|
|
428
|
+
result = build_report(data, output_path)
|
|
429
|
+
print(f"Report saved: {result}")
|
|
@@ -1,217 +1,166 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: qualia-report
|
|
3
|
-
description: "Generate a session work report
|
|
3
|
+
description: "Generate a session work report as a branded DOCX — simple summary of what was done. Use this skill whenever the user says 'report', 'done for today', 'session report', 'log my work', 'what did I do', 'end of day', 'wrapping up', 'signing off', or finishes a working session. Also trigger when user mentions 'daily report', 'status update', 'work summary', or wants to document what they accomplished."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Qualia Report — Session
|
|
6
|
+
# Qualia Report — Session DOCX Report
|
|
7
7
|
|
|
8
|
-
Generate a
|
|
8
|
+
Generate a clean, branded DOCX report of what was done in this session. Simple and clear — what happened, not every detail.
|
|
9
9
|
|
|
10
10
|
## Usage
|
|
11
11
|
|
|
12
|
-
`/qualia-report` — Generate report for current session
|
|
13
|
-
`/qualia-report --week` — Aggregate
|
|
14
|
-
|
|
15
|
-
## When to Run
|
|
16
|
-
|
|
17
|
-
The framework should suggest this when:
|
|
18
|
-
- Employee says "done for today", "stopping", "wrapping up", "signing off"
|
|
19
|
-
- Employee has been working for 1+ hours and mentions pausing
|
|
20
|
-
- A phase is completed or verified
|
|
21
|
-
- Before running `/qualia-pause-work` (report first, then pause)
|
|
12
|
+
`/qualia-report` — Generate DOCX report for current session
|
|
13
|
+
`/qualia-report --week` — Aggregate weekly DOCX report
|
|
22
14
|
|
|
23
15
|
## Process
|
|
24
16
|
|
|
25
17
|
### 1. Gather Session Data
|
|
26
18
|
|
|
27
|
-
**
|
|
19
|
+
Run a **single** bash command to collect everything:
|
|
20
|
+
|
|
28
21
|
```bash
|
|
29
|
-
|
|
30
|
-
LAST_REPORT=$(ls -t .planning/reports/report-*.md 2>/dev/null | head -1)
|
|
22
|
+
echo "---GIT---"
|
|
23
|
+
LAST_REPORT=$(ls -t .planning/reports/report-*.docx .planning/reports/report-*.md 2>/dev/null | head -1)
|
|
31
24
|
if [ -n "$LAST_REPORT" ]; then
|
|
32
|
-
|
|
25
|
+
SINCE_DATE=$(stat -c %Y "$LAST_REPORT" 2>/dev/null)
|
|
26
|
+
SINCE=$(date -d "@$SINCE_DATE" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "8 hours ago")
|
|
33
27
|
else
|
|
34
28
|
SINCE="8 hours ago"
|
|
35
29
|
fi
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
- Decisions made
|
|
54
|
-
|
|
55
|
-
**From UAT.md files (if any were created this session):**
|
|
56
|
-
- Test results
|
|
57
|
-
- Issues found
|
|
58
|
-
|
|
59
|
-
**From git diff (what files changed):**
|
|
60
|
-
```bash
|
|
61
|
-
git diff --name-only HEAD~5..HEAD 2>/dev/null | head -30
|
|
30
|
+
echo "SINCE:$SINCE"
|
|
31
|
+
echo "---COMMITS---"
|
|
32
|
+
git log --oneline --since="$SINCE" 2>/dev/null | head -20
|
|
33
|
+
echo "---STATS---"
|
|
34
|
+
COMMIT_COUNT=$(git log --oneline --since="$SINCE" 2>/dev/null | wc -l)
|
|
35
|
+
echo "COUNT:$COMMIT_COUNT"
|
|
36
|
+
FIRST=$(git log --format="%ar" --since="$SINCE" 2>/dev/null | tail -1)
|
|
37
|
+
LAST=$(git log --format="%ar" --since="$SINCE" 2>/dev/null | head -1)
|
|
38
|
+
echo "FIRST:$FIRST"
|
|
39
|
+
echo "LAST:$LAST"
|
|
40
|
+
echo "---PROJECT---"
|
|
41
|
+
echo "DIR:$(basename $(pwd))"
|
|
42
|
+
echo "BRANCH:$(git branch --show-current 2>/dev/null)"
|
|
43
|
+
echo "---STATE---"
|
|
44
|
+
test -f .planning/STATE.md && head -20 .planning/STATE.md || echo "no-state"
|
|
45
|
+
echo "---PHASE---"
|
|
46
|
+
test -f .planning/ROADMAP.md && grep -A2 "^## Phase" .planning/ROADMAP.md | head -6 || echo "no-roadmap"
|
|
62
47
|
```
|
|
63
48
|
|
|
64
|
-
### 2.
|
|
65
|
-
|
|
66
|
-
Compare what ROADMAP.md says the current phase should deliver against what was actually built:
|
|
67
|
-
|
|
68
|
-
- **Planned:** [from ROADMAP.md phase goal + success criteria]
|
|
69
|
-
- **Completed:** [from commits + SUMMARY.md]
|
|
70
|
-
- **Partially done:** [started but not finished]
|
|
71
|
-
- **Not started:** [planned but untouched]
|
|
72
|
-
- **Unplanned work:** [things done that weren't in the plan]
|
|
73
|
-
|
|
74
|
-
### 3. Generate Report
|
|
75
|
-
|
|
76
|
-
Create `.planning/reports/report-{YYYY-MM-DD-HHMM}.md`:
|
|
77
|
-
|
|
78
|
-
```markdown
|
|
79
|
-
# Session Report
|
|
80
|
-
|
|
81
|
-
**Project:** {from PROJECT.md or directory name}
|
|
82
|
-
**Date:** {YYYY-MM-DD HH:MM}
|
|
83
|
-
**Employee:** {from STATE.md "Assigned to" or git config user.name}
|
|
84
|
-
**Duration:** {estimated from first to last commit, or "~X hours"}
|
|
85
|
-
**Phase:** {N} of {total} — {phase name}
|
|
86
|
-
|
|
87
|
-
## What Was Assigned
|
|
88
|
-
|
|
89
|
-
{Phase goal from ROADMAP.md}
|
|
90
|
-
|
|
91
|
-
Success criteria:
|
|
92
|
-
1. {criterion 1}
|
|
93
|
-
2. {criterion 2}
|
|
94
|
-
3. {criterion 3}
|
|
95
|
-
|
|
96
|
-
## What Was Done
|
|
97
|
-
|
|
98
|
-
{Summary of actual work, derived from commits and file changes}
|
|
99
|
-
|
|
100
|
-
- {accomplishment 1}
|
|
101
|
-
- {accomplishment 2}
|
|
102
|
-
- {accomplishment 3}
|
|
103
|
-
|
|
104
|
-
### Files Changed
|
|
105
|
-
{Top 15 files modified, grouped by area}
|
|
106
|
-
|
|
107
|
-
### Commits
|
|
108
|
-
{List of commits this session}
|
|
109
|
-
|
|
110
|
-
## Progress
|
|
111
|
-
|
|
112
|
-
| Criterion | Status |
|
|
113
|
-
|-----------|--------|
|
|
114
|
-
| {criterion 1} | Done / Partial / Not started |
|
|
115
|
-
| {criterion 2} | Done / Partial / Not started |
|
|
116
|
-
| {criterion 3} | Done / Partial / Not started |
|
|
117
|
-
|
|
118
|
-
**Phase progress:** {X}% → {Y}% (moved {delta}%)
|
|
119
|
-
**Overall project:** {A}% → {B}%
|
|
49
|
+
### 2. Synthesize — Keep It Simple
|
|
120
50
|
|
|
121
|
-
|
|
51
|
+
From the gathered data, build a **concise** summary. The report should answer:
|
|
122
52
|
|
|
123
|
-
|
|
53
|
+
1. **What was done?** — 3-6 bullet points summarizing accomplishments in plain language. Group related commits into single items. Don't list every file changed.
|
|
54
|
+
2. **Any deviations?** — Only include if something was done differently from the plan. Skip this section entirely if everything went as planned.
|
|
55
|
+
3. **Any blockers?** — Only include if something is actually blocked. Skip if none.
|
|
56
|
+
4. **What's next?** — 1-3 clear next actions.
|
|
124
57
|
|
|
125
|
-
|
|
58
|
+
**Tone:** Write for a busy founder reviewing work. Clear, factual, no filler. Each bullet should start with a verb: "Built...", "Fixed...", "Added...", "Refactored..."
|
|
126
59
|
|
|
127
|
-
|
|
60
|
+
### 3. Generate DOCX
|
|
128
61
|
|
|
129
|
-
|
|
62
|
+
Build a JSON object and pipe it to the generator:
|
|
130
63
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
- {blocker 1}: {status — resolved / still blocking}
|
|
134
|
-
|
|
135
|
-
{If none: "None — clear session."}
|
|
136
|
-
|
|
137
|
-
## Decisions Made
|
|
138
|
-
|
|
139
|
-
{Any choices that affect future work}
|
|
140
|
-
|
|
141
|
-
| Decision | Why | Impact |
|
|
142
|
-
|----------|-----|--------|
|
|
143
|
-
| {choice} | {reason} | {what it affects} |
|
|
64
|
+
```bash
|
|
65
|
+
mkdir -p .planning/reports
|
|
144
66
|
|
|
145
|
-
|
|
67
|
+
cat <<'REPORT_JSON' | python3 ~/.claude/qualia-framework/bin/generate-report-docx.py ".planning/reports/report-$(date +%Y-%m-%d-%H%M).docx"
|
|
68
|
+
{
|
|
69
|
+
"project": "<project-name>",
|
|
70
|
+
"user": "<git config user.name or 'Fawzi Goussous'>",
|
|
71
|
+
"date": "<YYYY-MM-DD>",
|
|
72
|
+
"time": "<HH:MM>",
|
|
73
|
+
"branch": "<current-branch>",
|
|
74
|
+
"duration": "<estimated from first to last commit, e.g. ~3 hours>",
|
|
75
|
+
"phase": "<Phase N of M — name, or omit if no .planning>",
|
|
76
|
+
"done": [
|
|
77
|
+
"<accomplishment 1>",
|
|
78
|
+
"<accomplishment 2>",
|
|
79
|
+
"<accomplishment 3>"
|
|
80
|
+
],
|
|
81
|
+
"deviations": [],
|
|
82
|
+
"blockers": [],
|
|
83
|
+
"next": [
|
|
84
|
+
"<next action 1>",
|
|
85
|
+
"<next action 2>"
|
|
86
|
+
],
|
|
87
|
+
"commits": [
|
|
88
|
+
"<hash> <message>",
|
|
89
|
+
"<hash> <message>"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
REPORT_JSON
|
|
93
|
+
```
|
|
146
94
|
|
|
147
|
-
**
|
|
148
|
-
1. {immediate next action}
|
|
149
|
-
2. {following action}
|
|
95
|
+
**Important:** The JSON must be valid. Escape any quotes in commit messages. Omit `deviations` and `blockers` arrays if empty (the generator handles missing keys gracefully).
|
|
150
96
|
|
|
151
|
-
|
|
97
|
+
### 4. Commit & Auto-Upload to ERP
|
|
152
98
|
|
|
153
|
-
|
|
154
|
-
|
|
99
|
+
```bash
|
|
100
|
+
# Commit the report
|
|
101
|
+
git add .planning/reports/report-*.docx
|
|
102
|
+
git commit -m "report: session report $(date +%Y-%m-%d) — $(basename $(pwd))"
|
|
155
103
|
```
|
|
156
104
|
|
|
157
|
-
|
|
105
|
+
Then **auto-upload** the report to the ERP so it's linked to the employee's active work session. This is mandatory and happens automatically — employees cannot skip or edit the report before it reaches the ERP.
|
|
158
106
|
|
|
159
107
|
```bash
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
git
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
108
|
+
# Auto-upload to ERP
|
|
109
|
+
REPORT_FILE=$(ls -t .planning/reports/report-*.docx 2>/dev/null | head -1)
|
|
110
|
+
EMPLOYEE_EMAIL=$(git config user.email)
|
|
111
|
+
PROJECT_NAME=$(basename $(pwd))
|
|
112
|
+
ERP_URL="https://portal.qualiasolutions.net/api/claude/report-upload"
|
|
113
|
+
API_KEY=$(cat ~/.claude/.env 2>/dev/null | grep CLAUDE_API_KEY | cut -d= -f2 || echo "")
|
|
114
|
+
|
|
115
|
+
if [ -z "$API_KEY" ]; then
|
|
116
|
+
# Try from project env
|
|
117
|
+
API_KEY=$(grep CLAUDE_API_KEY .env.local 2>/dev/null | cut -d= -f2 || echo "")
|
|
118
|
+
fi
|
|
166
119
|
|
|
167
|
-
|
|
120
|
+
if [ -n "$REPORT_FILE" ] && [ -n "$API_KEY" ]; then
|
|
121
|
+
UPLOAD_RESULT=$(curl -s -X POST "$ERP_URL" \
|
|
122
|
+
-H "x-api-key: $API_KEY" \
|
|
123
|
+
-F "file=@$REPORT_FILE" \
|
|
124
|
+
-F "employee_email=$EMPLOYEE_EMAIL" \
|
|
125
|
+
-F "project_name=$PROJECT_NAME")
|
|
126
|
+
echo "ERP upload: $UPLOAD_RESULT"
|
|
127
|
+
else
|
|
128
|
+
echo "WARNING: Could not auto-upload report to ERP (missing API key or report file)"
|
|
129
|
+
fi
|
|
130
|
+
```
|
|
168
131
|
|
|
169
|
-
|
|
132
|
+
Then tell the user:
|
|
170
133
|
|
|
171
|
-
>
|
|
134
|
+
> Report saved to `.planning/reports/report-{date}.docx` and **auto-uploaded to ERP**.
|
|
172
135
|
>
|
|
173
|
-
>
|
|
174
|
-
>
|
|
175
|
-
|
|
176
|
-
## Weekly Aggregate (`--week`)
|
|
177
|
-
|
|
178
|
-
When run with `--week`, reads all reports from the last 7 days and produces a summary:
|
|
179
|
-
|
|
180
|
-
```markdown
|
|
181
|
-
# Weekly Report — {project name}
|
|
182
|
-
|
|
183
|
-
**Period:** {date} to {date}
|
|
184
|
-
**Employee:** {name}
|
|
185
|
-
|
|
186
|
-
## Summary
|
|
187
|
-
|
|
188
|
-
- **Sessions:** {count}
|
|
189
|
-
- **Phases progressed:** Phase {X} → Phase {Y}
|
|
190
|
-
- **Overall progress:** {A}% → {B}%
|
|
191
|
-
- **Commits:** {total}
|
|
192
|
-
|
|
193
|
-
## Per-Session Breakdown
|
|
136
|
+
> Your report is now linked to your active work session. When you clock out, it will already be attached.
|
|
137
|
+
>
|
|
138
|
+
> Want to save session context too? Run `/qualia-pause-work`
|
|
194
139
|
|
|
195
|
-
|
|
196
|
-
|------|-------|----------|-------------------|
|
|
197
|
-
| {date} | Phase 3 | +15% | Built auth flow |
|
|
198
|
-
| {date} | Phase 3 | +10% | Added RLS policies |
|
|
199
|
-
| {date} | Phase 4 | +5% | Started chat UI |
|
|
140
|
+
### 5. Update STATE.md (if exists)
|
|
200
141
|
|
|
201
|
-
|
|
142
|
+
Update `Last activity` and `Last worked by` fields.
|
|
202
143
|
|
|
203
|
-
|
|
144
|
+
## Weekly Report (`--week`)
|
|
204
145
|
|
|
205
|
-
|
|
146
|
+
When run with `--week`:
|
|
206
147
|
|
|
207
|
-
|
|
148
|
+
1. Read all reports from the last 7 days (check `.planning/reports/`)
|
|
149
|
+
2. Read git log for the full week
|
|
150
|
+
3. Build an aggregate JSON with:
|
|
151
|
+
- `"project"` — project name
|
|
152
|
+
- `"date"` — current date with "Weekly" suffix
|
|
153
|
+
- `"duration"` — "Week of {date} to {date}"
|
|
154
|
+
- `"done"` — combined accomplishments from all sessions, deduplicated
|
|
155
|
+
- `"next"` — forward-looking items from the most recent report
|
|
156
|
+
- `"commits"` — full week's commits
|
|
157
|
+
4. Pipe to the same generator, save as `weekly-{date}.docx`
|
|
208
158
|
|
|
209
|
-
##
|
|
159
|
+
## Generator Location
|
|
210
160
|
|
|
211
|
-
|
|
212
|
-
```
|
|
161
|
+
`~/.claude/qualia-framework/bin/generate-report-docx.py`
|
|
213
162
|
|
|
214
|
-
|
|
163
|
+
Requires: `python-docx` (pre-installed). Uses Qualia logo from `~/.claude/qualia-framework/assets/qualia-logo.png`.
|
|
215
164
|
|
|
216
165
|
---
|
|
217
166
|
> Stuck? Type `/qualia-idk` · Lost? Type `/qualia-help`
|