This fully implements the Stock Docs Project with full Docker Containerization support. This is a working prototype that is actively running on the Media Server. There's a few issues noted, including the following: - Support for some sites could be improved. Reuters has many articles behind an adblock and some websites present banners that don't need to be processed by our AI engine - Some caching could be smarter. As the size of files grows, it will get expensive to search through all files to be sure we've not scraped it, ai proccessed it or embedded it. - Logging could be improved to be much better than just print statements and telemetry could be sent for dashboard monitoring if this were ever to become a full service where we cared about reliability. - MCP server has been noted to return some poorly matching results. Would be better if it returned nothing at all. And should never really return banners or ads as that provides awful input for the model. Perhaps the model could be told to not care about this, but it's better to just never show irrelevant info to the model I think this is an overall really good jumping off point, and we've already gotten to see the max capabilities of our system so far. It's a major win to have the Scraper for instance running at all times getting articles from across the web. I look forward to expending this scraper in the near future for projects like scraping all local news websites in the US or general scraping and monitoring of websites. Co-authored-by: Jarian Cottingham <jariancottingham@dev-machine.local> Co-authored-by: jarianc <user@example.com> Reviewed-on: http://git.example.com/jarianc/StockDocs/pulls/2
169 lines
7.2 KiB
Python
169 lines
7.2 KiB
Python
import os
|
|
import requests
|
|
import json
|
|
import datetime
|
|
import time
|
|
|
|
LOCAL_AI_SERVICE_URL = os.getenv("AI_SERVICE_URL")
|
|
|
|
class ArticleFile:
|
|
def __init__(self, filename, content, source="Unfiltered"):
|
|
self.filename = filename
|
|
self.content = content
|
|
self.source = source
|
|
|
|
def __str__(self):
|
|
return f"ArticleFile(filename={self.filename}, source={self.source}, content_length={len(self.content)})"
|
|
|
|
def load_articles_from_folder(folder_path):
|
|
"""
|
|
Loads all articles from text files in the specified folder and returns them as a list of ArticleFile objects.
|
|
"""
|
|
articles = []
|
|
# Skip non-directory files in the root folder
|
|
for newspaper in os.listdir(folder_path):
|
|
newspaper_path = os.path.join(folder_path, newspaper)
|
|
if not os.path.isdir(newspaper_path):
|
|
continue
|
|
|
|
for filename in os.listdir(newspaper_path):
|
|
file_path = os.path.join(newspaper_path, filename)
|
|
output_path = os.path.join("output", f"{filename}.json")
|
|
|
|
# Skip if already processed
|
|
if os.path.isfile(output_path):
|
|
print(f"Skipping already processed article: {filename}")
|
|
continue
|
|
|
|
if os.path.isfile(file_path):
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
first_line = f.readline()
|
|
if first_line.startswith("SOURCE:"):
|
|
source = first_line[len("SOURCE:"):].strip()
|
|
content = f.read()
|
|
else:
|
|
source = "Unfiltered"
|
|
content = first_line + f.read()
|
|
articles.append(ArticleFile(filename, content, source))
|
|
|
|
print(f"Loaded {len(articles)} new articles from folder {folder_path}.")
|
|
return articles
|
|
|
|
def call_local_ai_service(article):
|
|
"""
|
|
Sends the article to the local Ollama AI server and returns the parsed response.
|
|
Trims any 'thinking' section from the DeepSeek model response.
|
|
"""
|
|
url = LOCAL_AI_SERVICE_URL + "/api/generate"
|
|
prompt = (
|
|
"Give a detailed summary of the article and then list out the tickers and their movement in the article. Do not say anything at the end of your response."
|
|
"Format the response as JSON with keys 'summary' and 'tickersAndMovements', where 'tickersAndMovements' is a list of "
|
|
"['Ticker', 'Movement direction', 'Percent change']. Here's an example of the expected output:\n"
|
|
"{'summary': '...', 'tickersAndMovements': [['AAPL', '+', '2.5%'], ['GOOGL', '-', '1.2%']]}\n\n" \
|
|
"Validate the JSON format and ensure it is parsable before giving the answer. For every entry in "
|
|
"tickersAndMovements, it must have 3 entries. If you don't know the ticker symbol, don't make one up, just give "
|
|
"the full name, you must have either +, - or = for the second entry, and you must have a decimal with percent (like 2.4%) or (?%) if you're unsure" \
|
|
"Do not say anything at the end of your response."
|
|
)
|
|
payload = {
|
|
"model": "llama3.2:latest",
|
|
"prompt": f"{prompt}\n\nArticle:\n{article}",
|
|
"stream": False,
|
|
"format": "json",
|
|
"options": {
|
|
"temperature": 0.1
|
|
}
|
|
}
|
|
|
|
print(f"Sending request to local AI service ...")
|
|
|
|
# Send the request to the local AI service
|
|
response = requests.post(url, json=payload, timeout=180) # Increased timeout to 300 seconds
|
|
response.raise_for_status()
|
|
# The AI should return a JSON string, so parse it
|
|
raw_response = response.json()["response"]
|
|
# Remove the 'thinking' section if present
|
|
think_marker = "</think>"
|
|
if think_marker in raw_response:
|
|
raw_response = raw_response.split(think_marker, 1)[1] # Take the second half after the marker
|
|
|
|
# Remove any leading code block markers or whitespace (e.g., ```json or ``` or whitespace)
|
|
raw_response = raw_response.lstrip("` \njson")
|
|
# Remove any trailing code block marker ```
|
|
if raw_response.endswith("```"):
|
|
raw_response = raw_response[:-3].rstrip()
|
|
|
|
#print(f"Received response (trimmed): {raw_response}")
|
|
print(f"Received response from local AI service, processing...")
|
|
ai_processed_results = json.loads(raw_response)
|
|
return ai_processed_results
|
|
|
|
def process_article(article_file):
|
|
processed_article = None
|
|
|
|
# Check if we already processed this article
|
|
# TODO : potentially expensive, consider using a database or cache
|
|
if os.path.exists(f"output/{article_file.filename}.json"):
|
|
print(f"Article {article_file.filename} already processed, skipping.")
|
|
|
|
# open file and return the content
|
|
with open(f"output/{article_file.filename}.json", 'r', encoding='utf-8') as f:
|
|
processed_article = json.load(f)
|
|
return processed_article
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
if not article_file.content.strip() or len(article_file.content) < 30:
|
|
print(f"Skipping empty article or article with insufficient content: {article_file.filename}")
|
|
return None
|
|
|
|
# Pass the article content to the AI service
|
|
processed_article = call_local_ai_service(article_file.content)
|
|
print(f"Processed article: {processed_article['summary'][:100]}...")
|
|
|
|
# Add metadata to the processed article
|
|
processed_article['filename'] = article_file.filename
|
|
processed_article['original_content'] = article_file.content
|
|
processed_article['source'] = article_file.source # <-- Add source here
|
|
break # Success, exit retry loop
|
|
except Exception as e:
|
|
print(f"\tError processing article (attempt {attempt+1}/3): {e}")
|
|
if processed_article is not None:
|
|
return processed_article
|
|
else:
|
|
print(f"\tSkipping article after 3 failed attempts.")
|
|
|
|
return processed_article
|
|
|
|
while True:
|
|
print("=========================================")
|
|
|
|
# Retrieve the current archive of pulled articles
|
|
articles_folder = os.path.join("/app/articles")
|
|
if not os.path.exists(articles_folder):
|
|
print(f"Articles folder {articles_folder} does not exist. Please check the path.")
|
|
exit(1)
|
|
print("Loading articles from folder " + articles_folder + " ...")
|
|
articles = load_articles_from_folder(articles_folder)
|
|
|
|
# Process the articles retrieved
|
|
results = []
|
|
failedProcessCount = 0
|
|
for article in articles:
|
|
try:
|
|
result = process_article(article)
|
|
|
|
if result is None:
|
|
failedProcessCount += 1
|
|
#print(f"Skipping article {article.filename} due to processing error.")
|
|
continue
|
|
|
|
# Save the processed result to a JSON file
|
|
with open("output/" + result['filename'] + ".json", "w", encoding="utf-8") as f:
|
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f"Error processing or saving article {article.filename}: {e}")
|
|
|
|
print(f"Processed {len(articles) - failedProcessCount} articles successfully, {failedProcessCount} failed to process.")
|
|
|
|
time.delay(60 * 5) # Wait for 5 minutes before the next iteration |