Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -45,12 +45,33 @@ else:
|
|
| 45 |
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
class NewsRequest(BaseModel):
|
| 49 |
url: str
|
| 50 |
id: Optional[str] = None
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
class SimpleClassifier(torch.nn.Module):
|
| 55 |
def __init__(self, input_dim):
|
| 56 |
super().__init__()
|
|
@@ -60,22 +81,21 @@ class SimpleClassifier(torch.nn.Module):
|
|
| 60 |
torch.nn.Linear(64, 1),
|
| 61 |
torch.nn.Sigmoid()
|
| 62 |
)
|
| 63 |
-
|
| 64 |
def forward(self, x):
|
| 65 |
return self.net(x)
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
headers = {"User-Agent": "Mozilla/5.0"}
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
return bs(
|
| 74 |
|
| 75 |
-
def parse_naver(soup):
|
| 76 |
title = soup.select_one("h2.media_end_head_headline") or soup.title
|
| 77 |
title_text = title.get_text(strip=True) if title else "제목 없음"
|
| 78 |
-
|
| 79 |
time_tag = soup.select_one("span.media_end_head_info_datestamp_time")
|
| 80 |
time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
|
| 81 |
|
|
@@ -85,13 +105,11 @@ def parse_naver(soup):
|
|
| 85 |
content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
|
| 86 |
else:
|
| 87 |
content = "본문 없음"
|
| 88 |
-
|
| 89 |
return title_text, time_text, content
|
| 90 |
|
| 91 |
-
def parse_daum(soup):
|
| 92 |
title = soup.select_one("h3.tit_view") or soup.title
|
| 93 |
title_text = title.get_text(strip=True) if title else "제목 없음"
|
| 94 |
-
|
| 95 |
time_tag = soup.select_one("span.num_date")
|
| 96 |
time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
|
| 97 |
|
|
@@ -101,278 +119,295 @@ def parse_daum(soup):
|
|
| 101 |
content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
|
| 102 |
else:
|
| 103 |
content = "본문 없음"
|
| 104 |
-
|
| 105 |
return title_text, time_text, content
|
| 106 |
|
| 107 |
-
def extract_thumbnail(soup):
|
| 108 |
tag = soup.find("meta", property="og:image")
|
| 109 |
return tag["content"] if tag and "content" in tag.attrs else None
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
model = genai.GenerativeModel('gemini-2.0-flash', generation_config=generation_config)
|
| 118 |
-
|
| 119 |
prompt = f"""
|
| 120 |
아래 내용을 참고해서 가장 연관성이 높은 주식 상장 회사 이름 하나만 말해줘.
|
| 121 |
다른 설명 없이 회사 이름만 대답해.
|
| 122 |
|
| 123 |
-
"{
|
| 124 |
"""
|
| 125 |
-
|
| 126 |
response = model.generate_content(prompt)
|
| 127 |
try:
|
| 128 |
-
|
| 129 |
except AttributeError:
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
@app.post("/ai/parse-news")
|
| 138 |
def parse_news(req: NewsRequest):
|
| 139 |
url = req.url.strip()
|
| 140 |
-
username = req.id.strip() if req.id else None
|
| 141 |
try:
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
sentiment = analyze_sentiment(content)
|
| 159 |
-
pos_score = sentiment["positive"]
|
| 160 |
-
neg_score = sentiment["negative"]
|
| 161 |
-
net_score = sentiment["neutral"]
|
| 162 |
-
print("부정:", sentiment["negative"])
|
| 163 |
-
print("중립:", sentiment["neutral"])
|
| 164 |
-
print("긍정:", sentiment["positive"])
|
| 165 |
-
|
| 166 |
-
# 중립 점수 절반으로 줄이고 나머지를 부정/긍정에 재분배
|
| 167 |
-
reduced_net = net_score / 2
|
| 168 |
-
remaining = net_score - reduced_net
|
| 169 |
-
total_non_neu = neg_score + pos_score
|
| 170 |
-
|
| 171 |
if total_non_neu > 0:
|
| 172 |
-
|
| 173 |
-
|
| 174 |
else:
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
net_score = reduced_net
|
| 179 |
-
|
| 180 |
-
max_label = max(
|
| 181 |
-
[("부정", neg_score), ("중립", net_score), ("긍정", pos_score)],
|
| 182 |
-
key=lambda x: x[1]
|
| 183 |
-
)[0]
|
| 184 |
|
|
|
|
| 185 |
if max_label == "긍정":
|
| 186 |
-
if
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
sentiment_label = f"긍정 ({pos_score*100:.1f}%)"
|
| 190 |
-
else:
|
| 191 |
-
sentiment_label = f"약한 긍정 ({pos_score*100:.1f}%)"
|
| 192 |
elif max_label == "부정":
|
| 193 |
-
if
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
sentiment_label = f"부정 ({neg_score*100:.1f}%)"
|
| 197 |
-
else:
|
| 198 |
-
sentiment_label = f"약한 부정 ({neg_score*100:.1f}%)"
|
| 199 |
else:
|
| 200 |
-
sentiment_label = f"중립 ({
|
| 201 |
-
|
| 202 |
-
#밑에 부분은 모델로 주가 예측 한거임
|
| 203 |
-
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
_, keywords_2nd = extract_keywords(summary)
|
| 209 |
clean_keywords = [kw for kw, _ in keywords_2nd]
|
| 210 |
-
|
| 211 |
keyword_vec = embed_keywords(clean_keywords)
|
| 212 |
-
input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0)
|
| 213 |
-
|
| 214 |
-
input_dim = input_vec.shape[1]
|
| 215 |
-
model = SimpleClassifier(input_dim)
|
| 216 |
-
|
| 217 |
model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
|
| 218 |
model.eval()
|
| 219 |
-
|
| 220 |
with torch.no_grad():
|
| 221 |
prob = model(input_vec).item()
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
prediction = '📈 상승 (1)' if prediction == 1 else '📉 하락 (0)'
|
| 226 |
-
print(type(prob))
|
| 227 |
-
print(type(prediction))
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
return {
|
|
|
|
| 232 |
"message": "뉴스 파싱 및 저장 완료",
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"content": content,
|
| 236 |
-
"thumbnail_url": thumbnail_url,
|
| 237 |
-
"url": url,
|
| 238 |
-
"summary": resultK["summary"],
|
| 239 |
-
"keyword": resultK["keyword"],
|
| 240 |
"company": targetCompany,
|
| 241 |
"sentiment": sentiment_label,
|
| 242 |
"sentiment_value": sentiment_label,
|
| 243 |
-
"prediction":
|
| 244 |
"prob": prob,
|
| 245 |
}
|
| 246 |
|
| 247 |
except requests.exceptions.RequestException as e:
|
| 248 |
-
traceback.print_exc()
|
| 249 |
raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
|
| 250 |
except Exception as e:
|
| 251 |
-
traceback.print_exc()
|
| 252 |
raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
#
|
| 257 |
krx_listings: pd.DataFrame = None
|
| 258 |
us_listings: pd.DataFrame = None
|
| 259 |
translator: Translator = None
|
| 260 |
|
| 261 |
-
# --- 서버 시작 시 실행될 로직 ---
|
| 262 |
@app.on_event("startup")
|
| 263 |
async def load_initial_data():
|
| 264 |
-
"""
|
| 265 |
-
서버가 시작될 때 주식 목록과 번역기를 미리 로드하여
|
| 266 |
-
API 요청마다 반복적으로 로드하는 것을 방지합니다.
|
| 267 |
-
"""
|
| 268 |
global krx_listings, us_listings, translator
|
| 269 |
-
|
| 270 |
logger.info("✅ 서버 시작: 초기 데이터 로딩을 시작합니다...")
|
| 271 |
try:
|
| 272 |
krx_listings = await run_in_threadpool(fdr.StockListing, 'KRX')
|
| 273 |
logger.info("📊 한국 상장 기업 목록 로딩 완료.")
|
| 274 |
-
|
| 275 |
nasdaq = await run_in_threadpool(fdr.StockListing, 'NASDAQ')
|
| 276 |
nyse = await run_in_threadpool(fdr.StockListing, 'NYSE')
|
| 277 |
amex = await run_in_threadpool(fdr.StockListing, 'AMEX')
|
| 278 |
us_listings = pd.concat([nasdaq, nyse, amex], ignore_index=True)
|
| 279 |
logger.info("📊 미국 상장 기업 목록 로딩 완료.")
|
| 280 |
-
|
| 281 |
translator = Translator()
|
| 282 |
logger.info("🌐 번역기 초기화 완료.")
|
| 283 |
-
|
| 284 |
-
logger.info("✅ 모든 초기 데이터 로딩이 성공적으로 완료되었습니다.")
|
| 285 |
-
|
| 286 |
except Exception as e:
|
| 287 |
-
logger.error(f"🚨 초기 데이터 로딩
|
| 288 |
-
# 필요하다면 여기서 서버 실행을 중단시킬 수도 있습니다.
|
| 289 |
-
# raise RuntimeError("Failed to load initial stock listings.") from e
|
| 290 |
|
| 291 |
-
# --- 핵심 로직 함수 ---
|
| 292 |
def get_stock_info(company_name: str) -> Dict[str, str] | None:
|
| 293 |
-
"""
|
| 294 |
-
회사명을 받아 한국 또는 미국 시장에서 종목 정보를 찾아 반환합니다.
|
| 295 |
-
(정상 동작하는 스크립트의 로직을 그대로 적용)
|
| 296 |
-
"""
|
| 297 |
-
# 1. 한국 주식에서 먼저 검색
|
| 298 |
kr_match = krx_listings[krx_listings['Name'].str.contains(company_name, case=False, na=False)]
|
| 299 |
if not kr_match.empty:
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
return {"market": "KRX", "symbol": stock['Code'], "name": stock['Name']}
|
| 303 |
-
|
| 304 |
-
# 2. 한국에 없으면 미국 주식에서 검색 (번역기 사용)
|
| 305 |
try:
|
| 306 |
-
# 번역은 I/O 작업이므로 스레드풀에서 실행하는 것이 더 안전할 수 있으나,
|
| 307 |
-
# googletrans의 내부 구현에 따라 여기서 직접 호출해도 큰 문제가 없을 수 있습니다.
|
| 308 |
company_name_eng = translator.translate(company_name, src='ko', dest='en').text
|
| 309 |
-
logger.info(f"'{company_name}' -> 영어로 번역: '{company_name_eng}'")
|
| 310 |
-
|
| 311 |
-
# 이름 또는 심볼에서 검색
|
| 312 |
us_match = us_listings[
|
| 313 |
us_listings['Name'].str.contains(company_name_eng, case=False, na=False) |
|
| 314 |
us_listings['Symbol'].str.fullmatch(company_name_eng, case=False)
|
| 315 |
]
|
| 316 |
-
|
| 317 |
if not us_match.empty:
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
return {"market": "US", "symbol": stock['Symbol'], "name": stock['Name']}
|
| 321 |
-
|
| 322 |
except Exception as e:
|
| 323 |
-
logger.error(f"
|
| 324 |
-
|
| 325 |
-
# 3. 최종적으로 찾지 못한 경우
|
| 326 |
-
logger.warning(f"'{company_name}'에 해당하는 종목을 찾지 못했습니다.")
|
| 327 |
return None
|
| 328 |
|
| 329 |
-
def fetch_stock_prices_sync(symbol: str, days: int = 365) -> pd.DataFrame:
|
| 330 |
-
"""
|
| 331 |
-
지정된 기간 동안의 주가 데이터를 가져옵니다 (동기 함수).
|
| 332 |
-
"""
|
| 333 |
end_date = datetime.today()
|
| 334 |
start_date = end_date - timedelta(days=days)
|
| 335 |
-
|
| 336 |
-
logger.info(f"FinanceDataReader로 '{symbol}'의 주가 데이터 조회를 시작합니다 ({start_date.date()} ~ {end_date.date()}).")
|
| 337 |
try:
|
| 338 |
df = fdr.DataReader(symbol, start=start_date, end=end_date)
|
| 339 |
if df.empty:
|
| 340 |
-
logger.warning(f"'{symbol}'에 대한 데이터가 없습니다.")
|
| 341 |
return None
|
| 342 |
return df
|
| 343 |
except Exception as e:
|
| 344 |
-
logger.error(f"'{symbol}' 데이터 조회
|
| 345 |
return None
|
| 346 |
|
| 347 |
-
# --- API 엔드포인트 ---
|
| 348 |
-
|
| 349 |
@app.get("/ai/stock-data/by-name",
|
| 350 |
summary="회사명으로 최근 1년 주가 데이터 조회 (JSON)",
|
| 351 |
-
description="회사명(예: 삼성전자, 애플)을 입력받아 최근 1년간의 일별 주가 데이터를 JSON 형식으로
|
| 352 |
-
async def get_stock_data_by_name(
|
| 353 |
-
company_name: str = Query(..., description="조회할 회사명")
|
| 354 |
-
) -> List[Dict[str, Any]]:
|
| 355 |
-
|
| 356 |
if not company_name or not company_name.strip():
|
| 357 |
raise HTTPException(status_code=400, detail="회사명을 입력해주세요.")
|
| 358 |
-
|
| 359 |
stock_info = await run_in_threadpool(get_stock_info, company_name.strip())
|
| 360 |
-
|
| 361 |
if not stock_info:
|
| 362 |
raise HTTPException(status_code=404, detail=f"'{company_name}'에 해당하는 종목을 찾을 수 없습니다.")
|
| 363 |
-
|
| 364 |
prices_df = await run_in_threadpool(fetch_stock_prices_sync, stock_info['symbol'], 365)
|
| 365 |
-
|
| 366 |
if prices_df is None or prices_df.empty:
|
| 367 |
raise HTTPException(status_code=404, detail=f"'{stock_info['name']}'의 시세 데이터를 찾을 수 없습니다.")
|
| 368 |
|
| 369 |
-
prices_df.index.name = 'Date'
|
| 370 |
prices_df.reset_index(inplace=True)
|
| 371 |
prices_df['Date'] = prices_df['Date'].dt.strftime('%Y-%m-%d')
|
| 372 |
-
|
| 373 |
return prices_df.to_dict(orient='records')
|
| 374 |
|
| 375 |
-
|
| 376 |
-
|
|
|
|
| 377 |
if __name__ == "__main__":
|
| 378 |
-
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
+
# ---------------------------------------
|
| 49 |
+
# 입력/출력 모델
|
| 50 |
+
# ---------------------------------------
|
| 51 |
class NewsRequest(BaseModel):
|
| 52 |
url: str
|
| 53 |
id: Optional[str] = None
|
| 54 |
|
| 55 |
+
class SummaryInput(BaseModel):
|
| 56 |
+
url: str
|
| 57 |
+
|
| 58 |
+
class KeywordsInput(BaseModel):
|
| 59 |
+
summary: str
|
| 60 |
+
|
| 61 |
+
class CompanyInput(BaseModel):
|
| 62 |
+
summary: Optional[str] = None
|
| 63 |
+
keywords: Optional[List[str]] = None
|
| 64 |
+
|
| 65 |
+
class SentimentInput(BaseModel):
|
| 66 |
+
content: str
|
| 67 |
+
|
| 68 |
+
class PredictInput(BaseModel):
|
| 69 |
+
keywords: List[Union[str, Dict[str, Any]]]
|
| 70 |
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------
|
| 73 |
+
# 간단한 분류기 (기존과 동일)
|
| 74 |
+
# ---------------------------------------
|
| 75 |
class SimpleClassifier(torch.nn.Module):
|
| 76 |
def __init__(self, input_dim):
|
| 77 |
super().__init__()
|
|
|
|
| 81 |
torch.nn.Linear(64, 1),
|
| 82 |
torch.nn.Sigmoid()
|
| 83 |
)
|
|
|
|
| 84 |
def forward(self, x):
|
| 85 |
return self.net(x)
|
| 86 |
|
| 87 |
+
# ---------------------------------------
|
| 88 |
+
# 공통 유틸: HTML, 파서, 썸네일
|
| 89 |
+
# ---------------------------------------
|
| 90 |
+
def fetch_html(url: str) -> bs:
|
| 91 |
headers = {"User-Agent": "Mozilla/5.0"}
|
| 92 |
+
resp = requests.get(url, headers=headers, timeout=7)
|
| 93 |
+
resp.raise_for_status()
|
| 94 |
+
return bs(resp.text, "html.parser")
|
| 95 |
|
| 96 |
+
def parse_naver(soup: bs):
|
| 97 |
title = soup.select_one("h2.media_end_head_headline") or soup.title
|
| 98 |
title_text = title.get_text(strip=True) if title else "제목 없음"
|
|
|
|
| 99 |
time_tag = soup.select_one("span.media_end_head_info_datestamp_time")
|
| 100 |
time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
|
| 101 |
|
|
|
|
| 105 |
content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
|
| 106 |
else:
|
| 107 |
content = "본문 없음"
|
|
|
|
| 108 |
return title_text, time_text, content
|
| 109 |
|
| 110 |
+
def parse_daum(soup: bs):
|
| 111 |
title = soup.select_one("h3.tit_view") or soup.title
|
| 112 |
title_text = title.get_text(strip=True) if title else "제목 없음"
|
|
|
|
| 113 |
time_tag = soup.select_one("span.num_date")
|
| 114 |
time_text = time_tag.get_text(strip=True) if time_tag else "시간 없음"
|
| 115 |
|
|
|
|
| 119 |
content = '\n'.join([p.get_text(strip=True) for p in paragraphs]) if paragraphs else content_area.get_text(strip=True)
|
| 120 |
else:
|
| 121 |
content = "본문 없음"
|
|
|
|
| 122 |
return title_text, time_text, content
|
| 123 |
|
| 124 |
+
def extract_thumbnail(soup: bs) -> Optional[str]:
|
| 125 |
tag = soup.find("meta", property="og:image")
|
| 126 |
return tag["content"] if tag and "content" in tag.attrs else None
|
| 127 |
|
| 128 |
+
def parse_article_all(url: str) -> Dict[str, Any]:
|
| 129 |
+
soup = fetch_html(url)
|
| 130 |
+
if "naver.com" in url:
|
| 131 |
+
title, time_str, content = parse_naver(soup)
|
| 132 |
+
elif "daum.net" in url:
|
| 133 |
+
title, time_str, content = parse_daum(soup)
|
| 134 |
+
else:
|
| 135 |
+
raise HTTPException(status_code=400, detail="지원하지 않는 뉴스 사이트입니다.")
|
| 136 |
+
thumbnail = extract_thumbnail(soup)
|
| 137 |
+
return {
|
| 138 |
+
"title": title,
|
| 139 |
+
"time": time_str,
|
| 140 |
+
"content": content,
|
| 141 |
+
"thumbnail_url": thumbnail,
|
| 142 |
+
"url": url,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
# ---------------------------------------
|
| 146 |
+
# 회사명 추론 (Gemini)
|
| 147 |
+
# ---------------------------------------
|
| 148 |
+
def gemini_use(text_for_company: str) -> str:
|
| 149 |
+
generation_config = genai.GenerationConfig(temperature=1)
|
| 150 |
model = genai.GenerativeModel('gemini-2.0-flash', generation_config=generation_config)
|
|
|
|
| 151 |
prompt = f"""
|
| 152 |
아래 내용을 참고해서 가장 연관성이 높은 주식 상장 회사 이름 하나만 말해줘.
|
| 153 |
다른 설명 없이 회사 이름만 대답해.
|
| 154 |
|
| 155 |
+
"{text_for_company}"
|
| 156 |
"""
|
|
|
|
| 157 |
response = model.generate_content(prompt)
|
| 158 |
try:
|
| 159 |
+
return response.text.strip()
|
| 160 |
except AttributeError:
|
| 161 |
+
return response.candidates[0].content.parts[0].text.strip()
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------
|
| 164 |
+
# 1) 요약 단계
|
| 165 |
+
# ---------------------------------------
|
| 166 |
+
@app.post("/ai/summary")
|
| 167 |
+
def step_summary(inp: SummaryInput):
|
| 168 |
+
meta = parse_article_all(inp.url)
|
| 169 |
+
# 너가 기존 resultKeyword를 먼저 쓰고 싶다면 이 한 줄로 대체 가능:
|
| 170 |
+
# rk = resultKeyword(meta["content"]); return {**meta, "summary": rk["summary"]}
|
| 171 |
+
summary_text = summarize(meta["content"])
|
| 172 |
+
return {**meta, "summary": summary_text}
|
| 173 |
+
|
| 174 |
+
# 2) 키워드 단계
|
| 175 |
+
@app.post("/ai/keywords")
|
| 176 |
+
def step_keywords(inp: KeywordsInput):
|
| 177 |
+
print("키워드는 옴")
|
| 178 |
+
try:
|
| 179 |
+
rk = resultKeyword(inp.summary)
|
| 180 |
+
return {"keywords": rk["keyword"]}
|
| 181 |
+
except Exception as e:
|
| 182 |
+
print("❌ 키워드 추출 오류:", e)
|
| 183 |
+
return {"keywords": []}
|
| 184 |
+
|
| 185 |
+
# 3) 관련 상장사 단계
|
| 186 |
+
@app.post("/ai/company")
|
| 187 |
+
def step_company(inp: CompanyInput):
|
| 188 |
+
if inp.summary:
|
| 189 |
+
text = inp.summary
|
| 190 |
+
elif inp.keywords:
|
| 191 |
+
text = ", ".join(inp.keywords)
|
| 192 |
+
else:
|
| 193 |
+
raise HTTPException(status_code=400, detail="summary 또는 keywords 중 하나가 필요합니다.")
|
| 194 |
+
company = gemini_use(text)
|
| 195 |
+
return {"company": company}
|
| 196 |
+
|
| 197 |
+
# 4) 감정 단계
|
| 198 |
+
@app.post("/ai/sentiment")
|
| 199 |
+
def step_sentiment(inp: SentimentInput):
|
| 200 |
+
s = analyze_sentiment(inp.content)
|
| 201 |
+
pos, neg, neu = s["positive"], s["negative"], s["neutral"]
|
| 202 |
+
# 중립 절반, 나머지 비율 재분배 (기존 로직)
|
| 203 |
+
reduced_net = neu / 2
|
| 204 |
+
remaining = neu - reduced_net
|
| 205 |
+
total_non_neu = neg + pos
|
| 206 |
+
if total_non_neu > 0:
|
| 207 |
+
neg += remaining * (neg / total_non_neu)
|
| 208 |
+
pos += remaining * (pos / total_non_neu)
|
| 209 |
+
else:
|
| 210 |
+
neg += remaining / 2
|
| 211 |
+
pos += remaining / 2
|
| 212 |
+
neu = reduced_net
|
| 213 |
+
|
| 214 |
+
max_label = max([("부정", neg), ("중립", neu), ("긍정", pos)], key=lambda x: x[1])[0]
|
| 215 |
+
if max_label == "긍정":
|
| 216 |
+
if pos >= 0.9: label = f"매우 긍정 ({pos*100:.1f}%)"
|
| 217 |
+
elif pos >= 0.6: label = f"긍정 ({pos*100:.1f}%)"
|
| 218 |
+
else: label = f"약한 긍정 ({pos*100:.1f}%)"
|
| 219 |
+
elif max_label == "부정":
|
| 220 |
+
if neg >= 0.9: label = f"매우 부정 ({neg*100:.1f}%)"
|
| 221 |
+
elif neg >= 0.6: label = f"부정 ({neg*100:.1f}%)"
|
| 222 |
+
else: label = f"약한 부정 ({neg*100:.1f}%)"
|
| 223 |
+
else:
|
| 224 |
+
label = f"중립 ({neu*100:.1f}%)"
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"raw": {"positive": s["positive"], "negative": s["negative"], "neutral": s["neutral"]},
|
| 228 |
+
"adjusted": {"positive": pos, "negative": neg, "neutral": neu},
|
| 229 |
+
"sentiment": label
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
# 5) 주가 예측 단계
|
| 233 |
+
@app.post("/ai/predict")
|
| 234 |
+
def step_predict(inp: PredictInput):
|
| 235 |
+
# 🔹 문자열 리스트로 정제 (딕셔너리인 경우 "word" 키 사용)
|
| 236 |
+
clean_keywords = []
|
| 237 |
+
for kw in inp.keywords:
|
| 238 |
+
if isinstance(kw, str):
|
| 239 |
+
clean_keywords.append(kw)
|
| 240 |
+
elif isinstance(kw, dict) and "word" in kw:
|
| 241 |
+
clean_keywords.append(kw["word"])
|
| 242 |
+
|
| 243 |
+
if not clean_keywords:
|
| 244 |
+
raise HTTPException(status_code=400, detail="keywords 리스트가 비어 있습니다.")
|
| 245 |
+
|
| 246 |
+
# 🔹 이하 기존 로직 동일
|
| 247 |
+
keyword_vec = embed_keywords(clean_keywords)
|
| 248 |
+
input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0)
|
| 249 |
+
input_dim = input_vec.shape[1]
|
| 250 |
+
|
| 251 |
+
model = SimpleClassifier(input_dim)
|
| 252 |
+
model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
|
| 253 |
+
model.eval()
|
| 254 |
+
|
| 255 |
+
with torch.no_grad():
|
| 256 |
+
prob = model(input_vec).item()
|
| 257 |
+
pred_label = '📈 상승 (1)' if prob >= 0.5 else '📉 하락 (0)'
|
| 258 |
+
|
| 259 |
+
return {"prediction": pred_label, "prob": prob}
|
| 260 |
+
|
| 261 |
+
# ---------------------------------------
|
| 262 |
+
# 호환용: 기존 parse-news (한방 요청) - 유지
|
| 263 |
+
# ---------------------------------------
|
| 264 |
@app.post("/ai/parse-news")
|
| 265 |
def parse_news(req: NewsRequest):
|
| 266 |
url = req.url.strip()
|
|
|
|
| 267 |
try:
|
| 268 |
+
meta = parse_article_all(url)
|
| 269 |
+
|
| 270 |
+
# 키워드/요약(기존 resultKeyword 사용)
|
| 271 |
+
rk = resultKeyword(meta["content"])
|
| 272 |
+
targetCompany = gemini_use(rk) # 텍스트 변환은 f-string 내부에서 처리됨
|
| 273 |
+
|
| 274 |
+
# 감정(기존 로직)
|
| 275 |
+
s = analyze_sentiment(meta["content"])
|
| 276 |
+
pos, neg, neu = s["positive"], s["negative"], s["neutral"]
|
| 277 |
+
print("부정:", neg)
|
| 278 |
+
print("중립:", neu)
|
| 279 |
+
print("긍정:", pos)
|
| 280 |
+
|
| 281 |
+
reduced_net = neu / 2
|
| 282 |
+
remaining = neu - reduced_net
|
| 283 |
+
total_non_neu = neg + pos
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
if total_non_neu > 0:
|
| 285 |
+
neg += remaining * (neg / total_non_neu)
|
| 286 |
+
pos += remaining * (pos / total_non_neu)
|
| 287 |
else:
|
| 288 |
+
neg += remaining / 2
|
| 289 |
+
pos += remaining / 2
|
| 290 |
+
neu = reduced_net
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
+
max_label = max([("부정", neg), ("중립", neu), ("긍정", pos)], key=lambda x: x[1])[0]
|
| 293 |
if max_label == "긍정":
|
| 294 |
+
if pos >= 0.9: sentiment_label = f"매우 긍정 ({pos*100:.1f}%)"
|
| 295 |
+
elif pos >= 0.6: sentiment_label = f"긍정 ({pos*100:.1f}%)"
|
| 296 |
+
else: sentiment_label = f"약한 긍정 ({pos*100:.1f}%)"
|
|
|
|
|
|
|
|
|
|
| 297 |
elif max_label == "부정":
|
| 298 |
+
if neg >= 0.9: sentiment_label = f"매우 부정 ({neg*100:.1f}%)"
|
| 299 |
+
elif neg >= 0.6: sentiment_label = f"부정 ({neg*100:.1f}%)"
|
| 300 |
+
else: sentiment_label = f"약한 부정 ({neg*100:.1f}%)"
|
|
|
|
|
|
|
|
|
|
| 301 |
else:
|
| 302 |
+
sentiment_label = f"중립 ({neu*100:.1f}%)"
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
+
# 예측
|
| 305 |
+
summary_text = rk.get("summary") or summarize(meta["content"])
|
| 306 |
+
_, keywords_2nd = extract_keywords(summary_text)
|
|
|
|
| 307 |
clean_keywords = [kw for kw, _ in keywords_2nd]
|
|
|
|
| 308 |
keyword_vec = embed_keywords(clean_keywords)
|
| 309 |
+
input_vec = torch.tensor(keyword_vec, dtype=torch.float32).unsqueeze(0)
|
| 310 |
+
model = SimpleClassifier(input_vec.shape[1])
|
|
|
|
|
|
|
|
|
|
| 311 |
model.load_state_dict(torch.load("news_model.pt", map_location="cpu"))
|
| 312 |
model.eval()
|
|
|
|
| 313 |
with torch.no_grad():
|
| 314 |
prob = model(input_vec).item()
|
| 315 |
+
prediction_label = '📈 상승 (1)' if prob >= 0.5 else '📉 하락 (0)'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
|
|
|
|
|
|
| 317 |
return {
|
| 318 |
+
**meta,
|
| 319 |
"message": "뉴스 파싱 및 저장 완료",
|
| 320 |
+
"summary": rk["summary"],
|
| 321 |
+
"keyword": rk["keyword"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
"company": targetCompany,
|
| 323 |
"sentiment": sentiment_label,
|
| 324 |
"sentiment_value": sentiment_label,
|
| 325 |
+
"prediction": prediction_label,
|
| 326 |
"prob": prob,
|
| 327 |
}
|
| 328 |
|
| 329 |
except requests.exceptions.RequestException as e:
|
| 330 |
+
traceback.print_exc()
|
| 331 |
raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
|
| 332 |
except Exception as e:
|
| 333 |
+
traceback.print_exc()
|
| 334 |
raise HTTPException(status_code=500, detail=f"서버 오류: {e}")
|
| 335 |
+
|
| 336 |
+
# ---------------------------------------
|
| 337 |
+
# 주가 데이터 (기존 유지)
|
| 338 |
+
# ---------------------------------------
|
| 339 |
krx_listings: pd.DataFrame = None
|
| 340 |
us_listings: pd.DataFrame = None
|
| 341 |
translator: Translator = None
|
| 342 |
|
|
|
|
| 343 |
@app.on_event("startup")
|
| 344 |
async def load_initial_data():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
global krx_listings, us_listings, translator
|
|
|
|
| 346 |
logger.info("✅ 서버 시작: 초기 데이터 로딩을 시작합니다...")
|
| 347 |
try:
|
| 348 |
krx_listings = await run_in_threadpool(fdr.StockListing, 'KRX')
|
| 349 |
logger.info("📊 한국 상장 기업 목록 로딩 완료.")
|
|
|
|
| 350 |
nasdaq = await run_in_threadpool(fdr.StockListing, 'NASDAQ')
|
| 351 |
nyse = await run_in_threadpool(fdr.StockListing, 'NYSE')
|
| 352 |
amex = await run_in_threadpool(fdr.StockListing, 'AMEX')
|
| 353 |
us_listings = pd.concat([nasdaq, nyse, amex], ignore_index=True)
|
| 354 |
logger.info("📊 미국 상장 기업 목록 로딩 완료.")
|
|
|
|
| 355 |
translator = Translator()
|
| 356 |
logger.info("🌐 번역기 초기화 완료.")
|
| 357 |
+
logger.info("✅ 초기 데이터 로딩 성공.")
|
|
|
|
|
|
|
| 358 |
except Exception as e:
|
| 359 |
+
logger.error(f"🚨 초기 데이터 로딩 오류: {e}", exc_info=True)
|
|
|
|
|
|
|
| 360 |
|
|
|
|
| 361 |
def get_stock_info(company_name: str) -> Dict[str, str] | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
kr_match = krx_listings[krx_listings['Name'].str.contains(company_name, case=False, na=False)]
|
| 363 |
if not kr_match.empty:
|
| 364 |
+
s = kr_match.iloc[0]
|
| 365 |
+
return {"market": "KRX", "symbol": s['Code'], "name": s['Name']}
|
|
|
|
|
|
|
|
|
|
| 366 |
try:
|
|
|
|
|
|
|
| 367 |
company_name_eng = translator.translate(company_name, src='ko', dest='en').text
|
|
|
|
|
|
|
|
|
|
| 368 |
us_match = us_listings[
|
| 369 |
us_listings['Name'].str.contains(company_name_eng, case=False, na=False) |
|
| 370 |
us_listings['Symbol'].str.fullmatch(company_name_eng, case=False)
|
| 371 |
]
|
|
|
|
| 372 |
if not us_match.empty:
|
| 373 |
+
s = us_match.iloc[0]
|
| 374 |
+
return {"market": "US", "symbol": s['Symbol'], "name": s['Name']}
|
|
|
|
|
|
|
| 375 |
except Exception as e:
|
| 376 |
+
logger.error(f"번역/미국 주식 검색 오류: {e}")
|
|
|
|
|
|
|
|
|
|
| 377 |
return None
|
| 378 |
|
| 379 |
+
def fetch_stock_prices_sync(symbol: str, days: int = 365) -> Optional[pd.DataFrame]:
|
|
|
|
|
|
|
|
|
|
| 380 |
end_date = datetime.today()
|
| 381 |
start_date = end_date - timedelta(days=days)
|
|
|
|
|
|
|
| 382 |
try:
|
| 383 |
df = fdr.DataReader(symbol, start=start_date, end=end_date)
|
| 384 |
if df.empty:
|
|
|
|
| 385 |
return None
|
| 386 |
return df
|
| 387 |
except Exception as e:
|
| 388 |
+
logger.error(f"'{symbol}' 데이터 조회 오류: {e}", exc_info=True)
|
| 389 |
return None
|
| 390 |
|
|
|
|
|
|
|
| 391 |
@app.get("/ai/stock-data/by-name",
|
| 392 |
summary="회사명으로 최근 1년 주가 데이터 조회 (JSON)",
|
| 393 |
+
description="회사명(예: 삼성전자, 애플)을 입력받아 최근 1년간의 일별 주가 데이터를 JSON 형식으로 반환")
|
| 394 |
+
async def get_stock_data_by_name(company_name: str = Query(..., description="조회할 회사명")) -> List[Dict[str, Any]]:
|
|
|
|
|
|
|
|
|
|
| 395 |
if not company_name or not company_name.strip():
|
| 396 |
raise HTTPException(status_code=400, detail="회사명을 입력해주세요.")
|
|
|
|
| 397 |
stock_info = await run_in_threadpool(get_stock_info, company_name.strip())
|
|
|
|
| 398 |
if not stock_info:
|
| 399 |
raise HTTPException(status_code=404, detail=f"'{company_name}'에 해당하는 종목을 찾을 수 없습니다.")
|
|
|
|
| 400 |
prices_df = await run_in_threadpool(fetch_stock_prices_sync, stock_info['symbol'], 365)
|
|
|
|
| 401 |
if prices_df is None or prices_df.empty:
|
| 402 |
raise HTTPException(status_code=404, detail=f"'{stock_info['name']}'의 시세 데이터를 찾을 수 없습니다.")
|
| 403 |
|
| 404 |
+
prices_df.index.name = 'Date'
|
| 405 |
prices_df.reset_index(inplace=True)
|
| 406 |
prices_df['Date'] = prices_df['Date'].dt.strftime('%Y-%m-%d')
|
|
|
|
| 407 |
return prices_df.to_dict(orient='records')
|
| 408 |
|
| 409 |
+
# ---------------------------------------
|
| 410 |
+
# 실행
|
| 411 |
+
# ---------------------------------------
|
| 412 |
if __name__ == "__main__":
|
| 413 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|