Doleeee commited on
Commit
940f8ff
·
1 Parent(s): 1256c52

pipeline 실행 결과 저장, async 적용 #3

Browse files
api_server.py CHANGED
@@ -1,22 +1,94 @@
1
- from fastapi import FastAPI, Request
 
 
 
 
 
2
  from pydantic import BaseModel
 
 
3
  from pipeline import pipeline
4
- from fastapi.responses import JSONResponse
5
 
6
  app = FastAPI()
7
 
8
- class QueryRequest(BaseModel):
 
9
  query: str
10
 
11
- @app.post("/analyze")
12
- async def analyze(request: QueryRequest):
13
- result = pipeline(request.query)
14
- # Convert AnalysisResult to dict for JSON serialization
15
- return JSONResponse(content={
16
- "query": result.query,
17
- "ticker": result.ticker,
18
- "analysis_type": result.analysis_type,
19
- "data_context": result.data_context,
20
- "llm_response": result.llm_response,
21
- "timestamp": getattr(result, "timestamp", None)
22
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from contextlib import suppress
4
+ from uuid import uuid4
5
+
6
+ from fastapi import FastAPI
7
  from pydantic import BaseModel
8
+ from fastapi.responses import StreamingResponse
9
+
10
  from pipeline import pipeline
 
11
 
12
  app = FastAPI()
13
 
14
+
15
+ class StreamAnalyzeRequest(BaseModel):
16
  query: str
17
 
18
+
19
+ @app.post("/analyze/")
20
+ async def analyze_stream(request: StreamAnalyzeRequest):
21
+ request_id = uuid4().hex[:8]
22
+ loop = asyncio.get_running_loop()
23
+ queue = asyncio.Queue()
24
+ state = {"result": None, "error": None}
25
+
26
+ def emit_event(payload: dict):
27
+ payload.setdefault("request_id", request_id)
28
+ loop.call_soon_threadsafe(queue.put_nowait, payload)
29
+
30
+ def on_delta(delta: str):
31
+ emit_event({"type": "delta", "delta": delta})
32
+
33
+ def on_status(message: str):
34
+ emit_event({"type": "status", "message": message})
35
+
36
+ async def run_pipeline_task():
37
+ try:
38
+ state["result"] = await pipeline(
39
+ request.query,
40
+ stream_output=True,
41
+ stream_callback=on_delta,
42
+ status_callback=on_status,
43
+ request_id=request_id,
44
+ )
45
+ except Exception as exc:
46
+ state["error"] = str(exc)
47
+ finally:
48
+ await queue.put(None)
49
+
50
+ task = asyncio.create_task(run_pipeline_task())
51
+
52
+ async def event_generator():
53
+ try:
54
+ while True:
55
+ event = await queue.get()
56
+ if event is None:
57
+ break
58
+ payload = json.dumps(event, ensure_ascii=False)
59
+ yield f"data: {payload}\n\n"
60
+
61
+ if state["error"]:
62
+ payload = json.dumps({"type": "error", "message": state["error"], "request_id": request_id}, ensure_ascii=False)
63
+ yield f"data: {payload}\n\n"
64
+ else:
65
+ result_payload = json.dumps(
66
+ {
67
+ "type": "result",
68
+ "result": {
69
+ "request_id": request_id,
70
+ "query": state["result"].query,
71
+ "ticker": state["result"].ticker,
72
+ "analysis_type": state["result"].analysis_type,
73
+ "data_context": state["result"].data_context,
74
+ "llm_response": state["result"].llm_response,
75
+ "timestamp": getattr(state["result"], "timestamp", None),
76
+ },
77
+ "request_id": request_id,
78
+ },
79
+ ensure_ascii=False,
80
+ )
81
+ yield f"data: {result_payload}\n\n"
82
+ yield f"data: {{\"type\":\"done\",\"request_id\":\"{request_id}\"}}\n\n"
83
+ finally:
84
+ if not task.done():
85
+ task.cancel()
86
+ with suppress(asyncio.CancelledError):
87
+ await task
88
+
89
+ headers = {
90
+ "Cache-Control": "no-cache",
91
+ "Connection": "keep-alive",
92
+ "X-Accel-Buffering": "no",
93
+ }
94
+ return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
data/collect_data.py CHANGED
@@ -1,6 +1,4 @@
1
- from typing import Any
2
- from datetime import datetime
3
- import yfinance as yf
4
 
5
  from data.earnings import fetch_earnings_data
6
  from data.finance_fetchers import fetch_fundamentals, fetch_news, fetch_price_data
@@ -9,7 +7,7 @@ from llm.web_search import fetch_web_search
9
  from utils.data_types import MarketData
10
 
11
 
12
- def collect_data(client, intent, tools):
13
  # 인텐트와 선택된 도구 목록을 기반으로 모든 시장 데이터 수집
14
  ticker = intent.get("ticker", "")
15
  period = intent.get("time_range", "1y")
@@ -25,31 +23,32 @@ def collect_data(client, intent, tools):
25
 
26
  if ticker:
27
  if "price" in tools:
28
- price_data = fetch_price_data(ticker, period)
29
  print(f" → 가격 데이터: {len(price_data)}개 지표")
30
 
31
  if "fundamentals" in tools:
32
- fundamentals = fetch_fundamentals(ticker)
33
  print(f" → 펀더멘털: {len(fundamentals)}개 지표")
34
 
35
  if "technicals" in tools:
36
- technicals = fetch_technicals(ticker, "6mo")
37
  print(f" → 기술지표: {len(technicals)}개 지표")
38
 
39
  if "news" in tools:
40
- news_snippets = fetch_news(ticker)
41
  print(f" → 뉴스: {len(news_snippets)}개 헤드라인")
42
 
43
  if "earnings" in tools:
44
- earnings_data = fetch_earnings_data(
 
45
  ticker,
46
  target_year=intent.get("target_year"),
47
- target_quarter=intent.get("target_quarter")
48
  )
49
 
50
  if "web_search" in tools:
51
  company_name = fundamentals.get("company_name", ticker) if fundamentals else ticker
52
- web_search_results = fetch_web_search(
53
  client=client,
54
  ticker=ticker,
55
  company_name=company_name,
 
1
+ import asyncio
 
 
2
 
3
  from data.earnings import fetch_earnings_data
4
  from data.finance_fetchers import fetch_fundamentals, fetch_news, fetch_price_data
 
7
  from utils.data_types import MarketData
8
 
9
 
10
+ async def collect_data(client, intent, tools):
11
  # 인텐트와 선택된 도구 목록을 기반으로 모든 시장 데이터 수집
12
  ticker = intent.get("ticker", "")
13
  period = intent.get("time_range", "1y")
 
23
 
24
  if ticker:
25
  if "price" in tools:
26
+ price_data = await asyncio.to_thread(fetch_price_data, ticker, period)
27
  print(f" → 가격 데이터: {len(price_data)}개 지표")
28
 
29
  if "fundamentals" in tools:
30
+ fundamentals = await asyncio.to_thread(fetch_fundamentals, ticker)
31
  print(f" → 펀더멘털: {len(fundamentals)}개 지표")
32
 
33
  if "technicals" in tools:
34
+ technicals = await asyncio.to_thread(fetch_technicals, ticker, "6mo")
35
  print(f" → 기술지표: {len(technicals)}개 지표")
36
 
37
  if "news" in tools:
38
+ news_snippets = await asyncio.to_thread(fetch_news, ticker)
39
  print(f" → 뉴스: {len(news_snippets)}개 헤드라인")
40
 
41
  if "earnings" in tools:
42
+ earnings_data = await asyncio.to_thread(
43
+ fetch_earnings_data,
44
  ticker,
45
  target_year=intent.get("target_year"),
46
+ target_quarter=intent.get("target_quarter"),
47
  )
48
 
49
  if "web_search" in tools:
50
  company_name = fundamentals.get("company_name", ticker) if fundamentals else ticker
51
+ web_search_results = await fetch_web_search(
52
  client=client,
53
  ticker=ticker,
54
  company_name=company_name,
intent/intent_parser.py CHANGED
@@ -31,8 +31,7 @@ def route_tools(intent):
31
  return selected
32
 
33
 
34
-
35
- def parse_intent(client, user_query):
36
  LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
37
  INTENT_TOOL = {
38
  "type": "function",
@@ -54,7 +53,7 @@ def parse_intent(client, user_query):
54
  }
55
  }
56
  }
57
- response = client.chat.completions.create(
58
  model=LLM_MODEL_NAME,
59
  messages=[
60
  {
 
31
  return selected
32
 
33
 
34
+ async def parse_intent(client, user_query):
 
35
  LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
36
  INTENT_TOOL = {
37
  "type": "function",
 
53
  }
54
  }
55
  }
56
+ response = await client.chat.completions.create(
57
  model=LLM_MODEL_NAME,
58
  messages=[
59
  {
llm/generator.py CHANGED
@@ -1,5 +1,6 @@
1
  import os
2
  import json
 
3
  from .prompts import SYSTEM_PROMPTS
4
  from data.news import search_google_news, format_news_list
5
 
@@ -7,6 +8,7 @@ from data.news import search_google_news, format_news_list
7
  # ⑤ LLM 분석 생성 (Responses API + web_search 상시 활성)
8
  # ─────────────────────────────────────────────
9
 
 
10
  def extract_response_text(resp):
11
  # Responses API 출력에서 텍스트 블록만 추출하여 합산
12
  texts = []
@@ -21,7 +23,7 @@ def extract_response_text(resp):
21
  return "\n".join(texts)
22
 
23
 
24
- def generate_search_keywords(client, user_query, intent):
25
  """LLM을 통해 구글 뉴스 검색어 리스트 생성"""
26
  language = intent.get("language", "ko")
27
  prompt = f"""
@@ -30,37 +32,56 @@ def generate_search_keywords(client, user_query, intent):
30
  [사용자 질의]
31
  {user_query}
32
  """
33
- LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
34
- resp = client.chat.completions.create(
35
  model=LLM_MODEL_NAME,
36
- messages=[{"role": "system", "content": "뉴스 검색 키워드 생성"},
37
- {"role": "user", "content": prompt}],
 
 
38
  )
39
- # 키워드 리스트 추출 (예: ['삼성전자', '반도체 전망', ...])
40
  content = resp.choices[0].message.content
41
  try:
42
  keywords = json.loads(content)
43
  if isinstance(keywords, list):
44
  return keywords
45
  except Exception:
46
- # 리스트 형태가 아니면 줄바꿈/쉼표로 분리
47
- return [k.strip() for k in content.replace('\n', ',').split(',') if k.strip()]
48
  return []
49
 
50
 
51
- def generate_news_info(client, user_query, intent):
52
- """LLM 키워드 생성 + 구글 뉴스 검색 + 포맷까지 한 번에 처리"""
53
  language = intent.get("language", "ko")
54
- keywords = generate_search_keywords(client, user_query, intent)
55
  news_list = search_google_news(keywords, language=language)
56
  news_str = format_news_list(news_list)
57
  return news_str
58
 
59
 
60
- def generate_analysis(client, user_query, context, intent, news_str):
61
- # Responses API + web_search로 최종 투자 분석 리포생성
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  analysis_type = intent.get("analysis_type", "general")
63
- language = intent.get("language", "ko")
64
  system_prompt = SYSTEM_PROMPTS.get(analysis_type, SYSTEM_PROMPTS["general"])
65
  system_prompt += f"\n\n반드시 {language} 언어로 답변하세요. 투자 조언이 아닌 정보 제공임을 명시하세요."
66
 
@@ -75,20 +96,122 @@ def generate_analysis(client, user_query, context, intent, news_str):
75
  [최신 구글 뉴스]
76
  {news_str} """
77
 
78
- LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
79
- print(f"[⑤] LLM 분석 생성 중 (Responses API, 모델: {LLM_MODEL_NAME})...")
 
 
 
 
 
 
80
 
81
- resp = client.responses.create(
82
  model=LLM_MODEL_NAME,
83
  tools=[
84
  {
85
  "type": "web_search",
86
- "user_location": {"type": "approximate", "country": "KR"}
87
  }
88
  ],
89
  input=full_input,
90
  )
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  result = extract_response_text(resp)
93
  return result or "(분석 결과를 가져오지 못했습니다)"
94
-
 
1
  import os
2
  import json
3
+ import time
4
  from .prompts import SYSTEM_PROMPTS
5
  from data.news import search_google_news, format_news_list
6
 
 
8
  # ⑤ LLM 분석 생성 (Responses API + web_search 상시 활성)
9
  # ─────────────────────────────────────────────
10
 
11
+
12
  def extract_response_text(resp):
13
  # Responses API 출력에서 텍스트 블록만 추출하여 합산
14
  texts = []
 
23
  return "\n".join(texts)
24
 
25
 
26
+ async def generate_search_keywords(client, user_query, intent):
27
  """LLM을 통해 구글 뉴스 검색어 리스트 생성"""
28
  language = intent.get("language", "ko")
29
  prompt = f"""
 
32
  [사용자 질의]
33
  {user_query}
34
  """
35
+ LLM_MODEL_NAME = os.environ.get("LLM_MODEL_NAME")
36
+ resp = await client.chat.completions.create(
37
  model=LLM_MODEL_NAME,
38
+ messages=[
39
+ {"role": "system", "content": "뉴스 검색 키워드 생성"},
40
+ {"role": "user", "content": prompt},
41
+ ],
42
  )
 
43
  content = resp.choices[0].message.content
44
  try:
45
  keywords = json.loads(content)
46
  if isinstance(keywords, list):
47
  return keywords
48
  except Exception:
49
+ return [k.strip() for k in content.replace("\n", ",").split(",") if k.strip()]
 
50
  return []
51
 
52
 
53
+ async def generate_news_info(client, user_query, intent):
54
+ """LLM 키워드 생성 + 구글 뉴스 검색 + 포맷까지 한 번에 처리 (Async LLM)"""
55
  language = intent.get("language", "ko")
56
+ keywords = await generate_search_keywords(client, user_query, intent)
57
  news_list = search_google_news(keywords, language=language)
58
  news_str = format_news_list(news_list)
59
  return news_str
60
 
61
 
62
+ def _extract_delta_text(event):
63
+ # OpenAI SDK 이벤트 객체(dict/typed)에서 delta 텍스 안전하게 추출
64
+ delta = getattr(event, "delta", None)
65
+ if delta:
66
+ return delta
67
+ if isinstance(event, dict):
68
+ return event.get("delta", "")
69
+ return ""
70
+
71
+
72
+ async def generate_analysis(
73
+ client,
74
+ user_query,
75
+ context,
76
+ intent,
77
+ news_str,
78
+ stream_output=False,
79
+ stream_callback=None,
80
+ log_callback=None,
81
+ ):
82
+ # Async Responses API로 최종 투자 분석 리포트 생성
83
  analysis_type = intent.get("analysis_type", "general")
84
+ language = intent.get("language", "ko")
85
  system_prompt = SYSTEM_PROMPTS.get(analysis_type, SYSTEM_PROMPTS["general"])
86
  system_prompt += f"\n\n반드시 {language} 언어로 답변하세요. 투자 조언이 아닌 정보 제공임을 명시하세요."
87
 
 
96
  [최신 구글 뉴스]
97
  {news_str} """
98
 
99
+ LLM_MODEL_NAME = os.environ.get("LLM_MODEL_NAME")
100
+ delta_log_every = int(os.environ.get("DELTA_LOG_EVERY", "20"))
101
+ delta_log_preview_chars = int(os.environ.get("DELTA_LOG_PREVIEW_CHARS", "80"))
102
+ start_msg = f"[⑤] LLM 분석 생성 중 (Responses API, 모델: {LLM_MODEL_NAME})..."
103
+ if log_callback:
104
+ log_callback(start_msg)
105
+ else:
106
+ print(start_msg)
107
 
108
+ request_kwargs = dict(
109
  model=LLM_MODEL_NAME,
110
  tools=[
111
  {
112
  "type": "web_search",
113
+ "user_location": {"type": "approximate", "country": "KR"},
114
  }
115
  ],
116
  input=full_input,
117
  )
118
 
119
+ if stream_output:
120
+ try:
121
+ all_deltas = []
122
+ first_event_logged = False
123
+ delta_count = 0
124
+ delta_total_chars = 0
125
+ response_created_at = None
126
+ first_delta_latency_logged = False
127
+
128
+ async with client.responses.stream(**request_kwargs) as stream:
129
+ if log_callback:
130
+ log_callback("[⑤][stream] stream context 진입 완료")
131
+ else:
132
+ print("[⑤][stream] stream context 진입 완료")
133
+
134
+ async for event in stream:
135
+ if not first_event_logged:
136
+ event_type = getattr(event, "type", None)
137
+ msg = f"[⑤][stream] 첫 이벤트 수신 type={event_type}"
138
+ if log_callback:
139
+ log_callback(msg)
140
+ else:
141
+ print(msg)
142
+ first_event_logged = True
143
+
144
+ if getattr(event, "type", None) == "response.created" and response_created_at is None:
145
+ response_created_at = time.perf_counter()
146
+
147
+ if getattr(event, "type", None) == "response.output_text.delta":
148
+ delta = _extract_delta_text(event)
149
+ if delta:
150
+ all_deltas.append(delta)
151
+ delta_count += 1
152
+ delta_total_chars += len(delta)
153
+
154
+ if response_created_at is not None and not first_delta_latency_logged:
155
+ latency_s = time.perf_counter() - response_created_at
156
+ latency_msg = f"[⑤][stream] response.created -> 첫 delta 지연: {latency_s:.3f}초"
157
+ if log_callback:
158
+ log_callback(latency_msg)
159
+ else:
160
+ print(latency_msg)
161
+ first_delta_latency_logged = True
162
+
163
+ should_log_delta = delta_count <= 3 or (delta_log_every > 0 and delta_count % delta_log_every == 0)
164
+ if should_log_delta:
165
+ preview = delta.replace("\n", "\\n")[:delta_log_preview_chars]
166
+ msg = f"[⑤][stream] delta#{delta_count} len={len(delta)} total_chars={delta_total_chars} preview='{preview}'"
167
+ if log_callback:
168
+ log_callback(msg)
169
+ else:
170
+ print(msg)
171
+
172
+ if stream_callback:
173
+ stream_callback(delta)
174
+ else:
175
+ print(delta, end="", flush=True)
176
+
177
+ if not stream_callback:
178
+ print()
179
+
180
+ if response_created_at is not None and not first_delta_latency_logged:
181
+ no_delta_msg = "[⑤][stream] response.created 이후 delta 미수신"
182
+ if log_callback:
183
+ log_callback(no_delta_msg)
184
+ else:
185
+ print(no_delta_msg)
186
+
187
+ summary_msg = f"[⑤][stream] delta 수집 요약: chunks={delta_count}, total_chars={delta_total_chars}"
188
+ if log_callback:
189
+ log_callback(summary_msg)
190
+ else:
191
+ print(summary_msg)
192
+
193
+ if log_callback:
194
+ log_callback("[⑤][stream] 최종 응답 수집 시작")
195
+ else:
196
+ print("[⑤][stream] 최종 응답 수집 시작")
197
+ final_resp = await stream.get_final_response()
198
+ if log_callback:
199
+ log_callback("[⑤][stream] 최종 응답 수집 완료")
200
+ else:
201
+ print("[⑤][stream] 최종 응답 수집 완료")
202
+
203
+ result = extract_response_text(final_resp)
204
+ if result:
205
+ return result
206
+ joined = "".join(all_deltas).strip()
207
+ return joined or "(분석 결과를 가져오지 못했습니다)"
208
+ except Exception as e:
209
+ err_msg = f"[⑤] 스트리밍 출력 실패, 일반 모드로 재시도: {e}"
210
+ if log_callback:
211
+ log_callback(err_msg)
212
+ else:
213
+ print(err_msg)
214
+
215
+ resp = await client.responses.create(**request_kwargs)
216
  result = extract_response_text(resp)
217
  return result or "(분석 결과를 가져오지 못했습니다)"
 
llm/web_search.py CHANGED
@@ -1,6 +1,7 @@
1
  from datetime import datetime
2
  import os
3
 
 
4
  def build_web_search_query(analysis_type, base, target_year, target_quarter, today):
5
  if analysis_type == "news_summary":
6
  return f"{today} 기준 {base} 최신 뉴스 및 주요 이슈"
@@ -14,6 +15,7 @@ def build_web_search_query(analysis_type, base, target_year, target_quarter, tod
14
  return f"{base} 최신 사업 현황, 경쟁사 동향, 리스크 요인 {today}"
15
  return f"{base} 최신 투자 정보 및 시장 동향 {today}"
16
 
 
17
  def extract_web_search_blocks(resp):
18
  results = []
19
  for item in resp.output:
@@ -22,12 +24,12 @@ def extract_web_search_blocks(resp):
22
  for block in (getattr(item, "content", []) or []):
23
  if getattr(block, "type", None) != "output_text":
24
  continue
25
- text = getattr(block, "text", "") or ""
26
  annotations = getattr(block, "annotations", []) or []
27
- citations = [
28
  {
29
  "title": getattr(ann, "title", ""),
30
- "url": getattr(ann, "url", ""),
31
  }
32
  for ann in annotations
33
  if getattr(ann, "type", "") == "url_citation"
@@ -36,7 +38,8 @@ def extract_web_search_blocks(resp):
36
  results.append({"text": text.strip(), "citations": citations})
37
  return results
38
 
39
- def fetch_web_search(client, ticker, company_name, analysis_type, language="ko", target_year=None, target_quarter=None):
 
40
  today = datetime.now().strftime("%Y년 %m월 %d일")
41
  base = f"{company_name}({ticker})" if ticker else company_name
42
  query = build_web_search_query(
@@ -46,7 +49,7 @@ def fetch_web_search(client, ticker, company_name, analysis_type, language="ko",
46
  results = []
47
  LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
48
  try:
49
- resp = client.responses.create(
50
  model=LLM_MODEL_NAME,
51
  tools=[
52
  {
 
1
  from datetime import datetime
2
  import os
3
 
4
+
5
  def build_web_search_query(analysis_type, base, target_year, target_quarter, today):
6
  if analysis_type == "news_summary":
7
  return f"{today} 기준 {base} 최신 뉴스 및 주요 이슈"
 
15
  return f"{base} 최신 사업 현황, 경쟁사 동향, 리스크 요인 {today}"
16
  return f"{base} 최신 투자 정보 및 시장 동향 {today}"
17
 
18
+
19
  def extract_web_search_blocks(resp):
20
  results = []
21
  for item in resp.output:
 
24
  for block in (getattr(item, "content", []) or []):
25
  if getattr(block, "type", None) != "output_text":
26
  continue
27
+ text = getattr(block, "text", "") or ""
28
  annotations = getattr(block, "annotations", []) or []
29
+ citations = [
30
  {
31
  "title": getattr(ann, "title", ""),
32
+ "url": getattr(ann, "url", ""),
33
  }
34
  for ann in annotations
35
  if getattr(ann, "type", "") == "url_citation"
 
38
  results.append({"text": text.strip(), "citations": citations})
39
  return results
40
 
41
+
42
+ async def fetch_web_search(client, ticker, company_name, analysis_type, language="ko", target_year=None, target_quarter=None):
43
  today = datetime.now().strftime("%Y년 %m월 %d일")
44
  base = f"{company_name}({ticker})" if ticker else company_name
45
  query = build_web_search_query(
 
49
  results = []
50
  LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME')
51
  try:
52
+ resp = await client.responses.create(
53
  model=LLM_MODEL_NAME,
54
  tools=[
55
  {
pipeline.py CHANGED
@@ -1,32 +1,125 @@
1
  import os
2
  import json
 
3
  import textwrap
 
4
  from datetime import datetime
5
- from typing import Any
6
- from dataclasses import dataclass, field
7
- from openai import OpenAI
8
 
 
 
9
 
10
- from intent.intent_parser import parse_intent
11
  from llm.generator import generate_analysis, generate_news_info
12
-
13
  from data.collect_data import collect_data
14
-
15
  from context.context_builder import build_context
16
- from intent.intent_parser import route_tools
17
  from utils.data_types import AnalysisResult
18
- from dotenv import load_dotenv
19
 
20
  load_dotenv()
21
 
22
 
23
  api_key = os.environ.get("LLM_MODEL_API_KEY")
24
  if api_key:
25
- client = OpenAI(api_key=api_key)
26
  else:
27
- client = OpenAI()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- def pipeline(query):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  """
31
  파이프라인:
32
  ① 인텐트 파싱 (Chat Completions + Function Calling)
@@ -35,43 +128,112 @@ def pipeline(query):
35
  ④ 컨텍스트 조립
36
  ⑤ 분석 생성 (Responses API + web_search 상시 활성)
37
  """
38
- global client
 
 
 
 
39
  print(f"\n{'='*60}")
40
- print(f"Wallstreet-AI 분석 시작: {query}")
41
  print('='*60)
42
 
43
- # 1. intent 싱, 2. tool 라우팅, 3. 데이터 수집
44
- intent = parse_intent(client, query)
45
- tools = route_tools(intent)
46
- market_data = collect_data(client, intent, tools)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- # 4. 구글 뉴스 정보 생 컨텍스트에 포함
49
- news_str = generate_news_info(client, query, intent)
50
- context = build_context(market_data, intent, news_str=news_str)
51
- print(f"[④] 컨텍스트 빌드 완료 ({len(context)} 문자)")
 
 
 
52
 
53
- # 5. 분석 생성 (news_str는 이미 context에 포함되었으므로 전달하지 않음)
54
- response = generate_analysis(client, query, context, intent, news_str=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
  result = AnalysisResult(
57
  query=query,
58
  ticker=intent.get("ticker", ""),
59
  analysis_type=intent.get("analysis_type", "general"),
60
  data_context={
61
- "price": market_data.price_data,
62
- "fundamentals": market_data.fundamentals,
63
- "technicals": market_data.technicals,
64
- "news_count": len(market_data.news_snippets),
65
- "earnings": market_data.earnings_data,
66
  "web_search_blocks": len(market_data.web_search_results),
67
- "google_news": news_str,
68
  },
69
- llm_response=response
70
  )
71
 
72
- print("\n[완료] 분석 완료 ✓")
73
- print('\ncontext info')
 
 
 
74
  print(context)
 
 
75
  return result
76
 
77
 
@@ -79,14 +241,16 @@ def pipeline(query):
79
  # CLI 출력 + 진입점
80
  # ─────────────────────────────────────────────
81
 
82
- def print_result(result):
83
  print(f"\n{'='*60}")
84
  print(f"분석 결과 | {result.ticker} | {result.analysis_type.upper()}")
85
  print(f"생성 시각: {result.timestamp}")
86
  print('='*60)
87
- for line in result.llm_response.split("\n"):
88
- print(textwrap.fill(line, width=80) if len(line) > 80 else line)
89
- print()
 
 
90
 
91
  ctx = result.data_context
92
  print("[수집 데이터 요약]")
@@ -99,20 +263,20 @@ def print_result(result):
99
  print(f" 웹 검색 블록 수: {ctx.get('web_search_blocks', 0)}")
100
  print(f" 구글 뉴스 요약 포함: {'예' if ctx.get('google_news') else '아니오'}")
101
 
102
- def main():
 
103
  while True:
104
  text = input("\n질문> ").strip()
105
  if text.lower() in ("exit", "quit", "종료"):
106
  break
107
  if not text:
108
  continue
109
- result = pipeline(text)
110
- print_result(result)
111
 
112
 
113
  if __name__ == "__main__":
114
- main()
115
 
116
  # FastAPI 실행 진입점
117
  # (uvicorn으로 실행: uvicorn api_server:app --reload)
118
-
 
1
  import os
2
  import json
3
+ import asyncio
4
  import textwrap
5
+ from contextlib import suppress
6
  from datetime import datetime
7
+ from pathlib import Path
8
+ from functools import partial
9
+ from uuid import uuid4
10
 
11
+ from openai import AsyncOpenAI
12
+ from dotenv import load_dotenv
13
 
14
+ from intent.intent_parser import parse_intent, route_tools
15
  from llm.generator import generate_analysis, generate_news_info
 
16
  from data.collect_data import collect_data
 
17
  from context.context_builder import build_context
 
18
  from utils.data_types import AnalysisResult
 
19
 
20
  load_dotenv()
21
 
22
 
23
  api_key = os.environ.get("LLM_MODEL_API_KEY")
24
  if api_key:
25
+ async_client = AsyncOpenAI(api_key=api_key)
26
  else:
27
+ async_client = AsyncOpenAI()
28
+
29
+ PIPELINE_LOG_PATH = Path("results/pipeline.jsonl")
30
+ WAITING_STATUS_MESSAGES = [
31
+ "답변 구조를 정리하고 있어요...",
32
+ "핵심 포인트를 우선순위로 정리 중이에요...",
33
+ "시장 데이터와 맥락을 교차 검증하고 있어요...",
34
+ "근거를 확인하면서 답변을 다듬고 있어요...",
35
+ "요약과 리스크 포인트를 함께 정리하고 있어요...",
36
+ "답변 완성도를 높이고 있어요...",
37
+ ]
38
+ WAITING_STATUS_INTERVAL_RANGE_SECONDS = 12
39
+
40
+
41
+ def append_pipeline_jsonl_log(result, executed_at, request_id, process_timings=None):
42
+ PIPELINE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ log_record = {
45
+ "request_id": request_id,
46
+ "executed_at": executed_at,
47
+ "query": result.query,
48
+ "ticker": result.ticker,
49
+ "analysis_type": result.analysis_type,
50
+ "timestamp": result.timestamp,
51
+ "data_context": result.data_context,
52
+ "llm_response": result.llm_response,
53
+ "process_timings": process_timings or {},
54
+ }
55
+
56
+ with PIPELINE_LOG_PATH.open("a", encoding="utf-8") as f:
57
+ f.write(json.dumps(log_record, ensure_ascii=False) + "\n")
58
+
59
+
60
+ def _emit_status(message, status_callback=None):
61
+ if status_callback:
62
+ status_callback(message)
63
+
64
+
65
+ def _now_str():
66
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
67
+
68
+
69
+ def _req_prefix(request_id):
70
+ return f"[req:{request_id}]"
71
+
72
+
73
+ def _req_log_and_status_callback(request_id, on_latency_marker=None):
74
+ def _log(message):
75
+ print(f"{_req_prefix(request_id)} {message}")
76
+ if on_latency_marker and "response.created -> 첫 delta 지연:" in message:
77
+ on_latency_marker()
78
+
79
+ return _log
80
+
81
+
82
+ async def _emit_waiting_statuses_in_order(status_callback, stop_event):
83
+ if not status_callback:
84
+ return
85
+
86
+ index = 0
87
+ message_count = len(WAITING_STATUS_MESSAGES)
88
+
89
+ while not stop_event.is_set():
90
+ await asyncio.sleep(WAITING_STATUS_INTERVAL_RANGE_SECONDS)
91
+ if stop_event.is_set():
92
+ break
93
+ _emit_status(WAITING_STATUS_MESSAGES[index], status_callback)
94
+ index = (index + 1) % message_count
95
 
96
+
97
+ async def _run_stage(label, coro, stream_output, process_timings, request_id):
98
+ started_at_dt = datetime.now()
99
+ started_at = started_at_dt.strftime("%Y-%m-%d %H:%M:%S")
100
+
101
+ if stream_output:
102
+ print(f"{_req_prefix(request_id)} [{label}] 시작: {started_at}")
103
+
104
+ value = await coro
105
+
106
+ ended_at_dt = datetime.now()
107
+ ended_at = ended_at_dt.strftime("%Y-%m-%d %H:%M:%S")
108
+ elapsed_seconds = round((ended_at_dt - started_at_dt).total_seconds(), 3)
109
+
110
+ process_timings[label] = {
111
+ "started_at": started_at,
112
+ "ended_at": ended_at,
113
+ "elapsed_seconds": elapsed_seconds,
114
+ }
115
+
116
+ if stream_output:
117
+ print(f"{_req_prefix(request_id)} [{label}] 종료: {ended_at} (소요 {elapsed_seconds}초)")
118
+
119
+ return value
120
+
121
+
122
+ async def pipeline(query, stream_output=False, stream_callback=None, status_callback=None, request_id=None):
123
  """
124
  파이프라인:
125
  ① 인텐트 파싱 (Chat Completions + Function Calling)
 
128
  ④ 컨텍스트 조립
129
  ⑤ 분석 생성 (Responses API + web_search 상시 활성)
130
  """
131
+ global async_client
132
+ request_id = request_id or uuid4().hex[:8]
133
+ executed_at = _now_str()
134
+ process_timings = {}
135
+
136
  print(f"\n{'='*60}")
137
+ print(f"{_req_prefix(request_id)} Wallstreet-AI 분석 시작: {query}")
138
  print('='*60)
139
 
140
+ _emit_status("질문 의도를악하고 있어요...", status_callback)
141
+ intent = await _run_stage(
142
+ "① 인텐트 파싱",
143
+ parse_intent(async_client, query),
144
+ stream_output,
145
+ process_timings,
146
+ request_id,
147
+ )
148
+
149
+ _emit_status("분석에 필요한 도구를 고르고 있어요...", status_callback)
150
+ tools = await _run_stage(
151
+ "② Tool 라우팅",
152
+ asyncio.to_thread(route_tools, intent),
153
+ stream_output,
154
+ process_timings,
155
+ request_id,
156
+ )
157
+
158
+ _emit_status("시장 데이터를 수집하고 있어요...", status_callback)
159
+ market_data = await _run_stage(
160
+ "③ 데이터 수집",
161
+ collect_data(async_client, intent, tools),
162
+ stream_output,
163
+ process_timings,
164
+ request_id,
165
+ )
166
+
167
+ _emit_status("수집 데이터를 정리하고 있어요...", status_callback)
168
+ news_str = await generate_news_info(async_client, query, intent)
169
+ context = await _run_stage(
170
+ "④ 컨텍스트 조립",
171
+ asyncio.to_thread(partial(build_context, market_data, intent, news_str=news_str)),
172
+ stream_output,
173
+ process_timings,
174
+ request_id,
175
+ )
176
+ print(f"{_req_prefix(request_id)} [④] 컨텍스트 빌드 완료 ({len(context)} 문자)")
177
 
178
+ _emit_status("AI가 답변을 하고 있어요...", status_callback)
179
+ waiting_stop_event = asyncio.Event()
180
+ waiting_task = None
181
+ if status_callback and stream_output:
182
+ waiting_task = asyncio.create_task(
183
+ _emit_waiting_statuses_in_order(status_callback, waiting_stop_event)
184
+ )
185
 
186
+ try:
187
+ response = await _run_stage(
188
+ "⑤ 분석 생성",
189
+ generate_analysis(
190
+ async_client,
191
+ query,
192
+ context,
193
+ intent,
194
+ news_str=news_str,
195
+ stream_output=stream_output,
196
+ stream_callback=stream_callback,
197
+ log_callback=_req_log_and_status_callback(
198
+ request_id,
199
+ on_latency_marker=waiting_stop_event.set,
200
+ ),
201
+ ),
202
+ stream_output,
203
+ process_timings,
204
+ request_id,
205
+ )
206
+ finally:
207
+ waiting_stop_event.set()
208
+ if waiting_task:
209
+ waiting_task.cancel()
210
+ with suppress(asyncio.CancelledError):
211
+ await waiting_task
212
 
213
  result = AnalysisResult(
214
  query=query,
215
  ticker=intent.get("ticker", ""),
216
  analysis_type=intent.get("analysis_type", "general"),
217
  data_context={
218
+ "price": market_data.price_data,
219
+ "fundamentals": market_data.fundamentals,
220
+ "technicals": market_data.technicals,
221
+ "news_count": len(market_data.news_snippets),
222
+ "earnings": market_data.earnings_data,
223
  "web_search_blocks": len(market_data.web_search_results),
224
+ "google_news": news_str,
225
  },
226
+ llm_response=response,
227
  )
228
 
229
+ await asyncio.to_thread(append_pipeline_jsonl_log, result, executed_at, request_id, process_timings)
230
+ print(f"{_req_prefix(request_id)} [로그] JSONL 저장 완료: {PIPELINE_LOG_PATH}")
231
+
232
+ print(f"\n{_req_prefix(request_id)} [완료] 분석 완료 ✓")
233
+ print(f"{_req_prefix(request_id)} context info")
234
  print(context)
235
+
236
+ _emit_status("답변 준비가 끝났어요.", status_callback)
237
  return result
238
 
239
 
 
241
  # CLI 출력 + 진입점
242
  # ─────────────────────────────────────────────
243
 
244
+ def print_result(result, include_response=True):
245
  print(f"\n{'='*60}")
246
  print(f"분석 결과 | {result.ticker} | {result.analysis_type.upper()}")
247
  print(f"생성 시각: {result.timestamp}")
248
  print('='*60)
249
+
250
+ if include_response:
251
+ for line in result.llm_response.split("\n"):
252
+ print(textwrap.fill(line, width=80) if len(line) > 80 else line)
253
+ print()
254
 
255
  ctx = result.data_context
256
  print("[수집 데이터 요약]")
 
263
  print(f" 웹 검색 블록 수: {ctx.get('web_search_blocks', 0)}")
264
  print(f" 구글 뉴스 요약 포함: {'예' if ctx.get('google_news') else '아니오'}")
265
 
266
+
267
+ async def main():
268
  while True:
269
  text = input("\n질문> ").strip()
270
  if text.lower() in ("exit", "quit", "종료"):
271
  break
272
  if not text:
273
  continue
274
+ result = await pipeline(text, stream_output=True)
275
+ print_result(result, include_response=False)
276
 
277
 
278
  if __name__ == "__main__":
279
+ asyncio.run(main())
280
 
281
  # FastAPI 실행 진입점
282
  # (uvicorn으로 실행: uvicorn api_server:app --reload)
 
requirements.txt CHANGED
@@ -8,4 +8,6 @@ tqdm
8
  jupyter
9
  openai
10
  fastapi
11
- uvicorn
 
 
 
8
  jupyter
9
  openai
10
  fastapi
11
+ uvicorn
12
+ dotenv
13
+ gradio