1818
1919import asyncio
2020import argparse
21- import json
2221import re
2322import sys
2423from datetime import date
2726
2827load_dotenv ()
2928
29+ from artemis .writers import write_json , write_markdown , md_to_docx # noqa: E402
30+
3031_FORMATS = ("json" , "md" , "docx" )
3132_PRESETS = ("deep" , "shallow" )
3233
@@ -48,126 +49,6 @@ def _default_output_path(query: str, fmt: str) -> str:
4849 return f"{ slug } -{ today } .{ ext } "
4950
5051
51- def _format_sources (results : list ) -> str :
52- """Build a numbered sources list from search results."""
53- if not results :
54- return ""
55- lines = ["\n \n ---\n \n ## Sources\n " ]
56- for i , r in enumerate (results , 1 ):
57- title = r .title or r .url
58- lines .append (f"{ i } . [{ title } ]({ r .url } )" )
59- return "\n " .join (lines )
60-
61-
62- def _write_json (path : str , query : str , result , * , stdout : bool = False ) -> None :
63- """Write results as JSON."""
64- output = {
65- "query" : query ,
66- "essay" : result .essay ,
67- "results" : [
68- {"title" : r .title , "url" : r .url , "snippet" : r .snippet }
69- for r in result .results
70- ],
71- "usage" : result .usage .model_dump () if result .usage else None ,
72- }
73- if stdout :
74- json .dump (output , sys .stdout , indent = 2 )
75- print ()
76- else :
77- with open (path , "w" ) as f :
78- json .dump (output , f , indent = 2 )
79-
80-
81- def _write_markdown (path : str , query : str , result , * , stdout : bool = False ) -> None :
82- """Write essay as Markdown with a sources appendix."""
83- content = result .essay + _format_sources (result .results )
84- if stdout :
85- print (content )
86- else :
87- with open (path , "w" ) as f :
88- f .write (content )
89-
90-
91- def _add_hyperlink (paragraph , url : str , text : str ):
92- """Add a clickable hyperlink to a python-docx paragraph."""
93- from docx .oxml .ns import qn
94- from docx .oxml import OxmlElement
95-
96- part = paragraph .part
97- r_id = part .relate_to (url , "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" , is_external = True )
98-
99- hyperlink = OxmlElement ("w:hyperlink" )
100- hyperlink .set (qn ("r:id" ), r_id )
101-
102- r = OxmlElement ("w:r" )
103- rPr = OxmlElement ("w:rPr" )
104- rStyle = OxmlElement ("w:rStyle" )
105- rStyle .set (qn ("w:val" ), "Hyperlink" )
106- rPr .append (rStyle )
107- r .append (rPr )
108-
109- t = OxmlElement ("w:t" )
110- t .text = text
111- r .append (t )
112- hyperlink .append (r )
113- paragraph ._p .append (hyperlink )
114- return hyperlink
115-
116-
117- def _write_docx (path : str , query : str , result , ** _kwargs ) -> None :
118- """Convert the markdown essay into a formatted DOCX document."""
119- from docx import Document
120- from docx .enum .text import WD_ALIGN_PARAGRAPH
121-
122- doc = Document ()
123-
124- # Title
125- title_para = doc .add_heading (query , level = 0 )
126- title_para .alignment = WD_ALIGN_PARAGRAPH .CENTER
127-
128- # Parse markdown line by line
129- for line in result .essay .split ("\n " ):
130- stripped = line .strip ()
131- if not stripped :
132- doc .add_paragraph ("" )
133- continue
134-
135- # Headings
136- if stripped .startswith ("#### " ):
137- doc .add_heading (stripped [5 :], level = 4 )
138- elif stripped .startswith ("### " ):
139- doc .add_heading (stripped [4 :], level = 3 )
140- elif stripped .startswith ("## " ):
141- doc .add_heading (stripped [3 :], level = 2 )
142- elif stripped .startswith ("# " ):
143- doc .add_heading (stripped [2 :], level = 1 )
144- elif stripped .startswith ("- " ) or stripped .startswith ("* " ):
145- doc .add_paragraph (stripped [2 :], style = "List Bullet" )
146- elif re .match (r"^\d+\.\s" , stripped ):
147- text = re .sub (r"^\d+\.\s" , "" , stripped )
148- doc .add_paragraph (text , style = "List Number" )
149- else :
150- doc .add_paragraph (stripped )
151-
152- # Sources appendix
153- if result .results :
154- doc .add_page_break ()
155- doc .add_heading ("Sources" , level = 1 )
156- for i , r in enumerate (result .results , 1 ):
157- title = r .title or r .url
158- para = doc .add_paragraph (f"{ i } . " , style = "List Number" )
159- _add_hyperlink (para , r .url , title )
160-
161- doc .save (path )
162-
163-
164- _WRITERS = {
165- "json" : _write_json ,
166- "md" : _write_markdown ,
167- "docx" : _write_docx ,
168- }
169-
170-
17152def _progress_callback (quiet : bool ):
17253 """Return a progress callback (or None if quiet)."""
17354 if quiet :
@@ -256,23 +137,26 @@ async def run_research(
256137 sys .exit (1 )
257138
258139 # Write output
259- writer = _WRITERS [fmt ]
260- if stdout :
261- writer (output , query , result , stdout = True )
262- else :
263- writer (output , query , result )
264- if not quiet :
265- print (f"\n ✅ Saved to { output } " , file = sys .stderr )
140+ usage_dict = result .usage .model_dump () if result .usage else None
141+ if fmt == "json" :
142+ write_json (output , query , result .essay , result .results , usage_dict , stdout = stdout )
143+ elif fmt == "md" :
144+ write_markdown (output , result .essay , result .results , stdout = stdout )
145+ elif fmt == "docx" :
146+ md_to_docx (output , result .essay , title = query , results = result .results )
147+
148+ if not stdout and not quiet :
149+ print (f"\n ✅ Saved to { output } " , file = sys .stderr )
150+ print (
151+ f" { len (result .essay )} chars | { len (result .results )} sources" ,
152+ file = sys .stderr ,
153+ )
154+ if result .usage :
266155 print (
267- f" { len (result .essay )} chars | { len (result .results )} sources" ,
156+ f" Tokens: { result .usage .total_tokens } "
157+ f"(in={ result .usage .input_tokens } out={ result .usage .output_tokens } )" ,
268158 file = sys .stderr ,
269159 )
270- if result .usage :
271- print (
272- f" Tokens: { result .usage .total_tokens } "
273- f"(in={ result .usage .input_tokens } out={ result .usage .output_tokens } )" ,
274- file = sys .stderr ,
275- )
276160
277161
278162def main () -> None :
0 commit comments