Skip to content

Commit 0a74573

Browse files
Merge pull request #67 from CMU-17313Q/p4-ci-workflow
P4 CI LLM Workflow
2 parents e951eb0 + 5b9cef2 commit 0a74573

File tree

4 files changed

+190
-0
lines changed

4 files changed

+190
-0
lines changed

.github/workflows/P4ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: P4CI Pipeline
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout repository
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Python
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.10"
21+
22+
- name: Install dependencies
23+
run: |
24+
python -m pip install --upgrade pip
25+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
26+
- name: Run tests with PYTHONPATH
27+
run: |
28+
export PYTHONPATH="$PYTHONPATH:$(pwd)/src"
29+
pytest -q

requirements.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
pytest
2+
pytest-mock
3+
4+
# Only include ollama if your team actually needs it
5+
# but keep it optional-friendly for CI
6+
ollama
7+
8+
# If your notebook / code uses any of these, include them:
9+
requests
10+
python-dotenv

src/llm_experiment.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
from typing import Tuple
3+
import os
4+
# LLM client setup copied from notebook
5+
try:
6+
from ollama import Client
7+
except Exception: # if ollama isn't installed in some env, don't crash imports
8+
Client = None # type: ignore
9+
MODEL_NAME = os.getenv("MODEL_NAME", "Llama3.1:8b")
10+
OLLAMA_URL = os.getenv("OLLAMA_HOST", "localhost:11434")
11+
class _DummyClient:
12+
"""Fallback so that importing this module doesn't explode in CI."""
13+
def chat(self, *args, **kwargs):
14+
class Message:
15+
def __init__(self, content):
16+
self.content = content
17+
class Response:
18+
def __init__(self, content):
19+
self.message = Message(content)
20+
return Response("dummy response")
21+
if Client:
22+
client = Client(host=OLLAMA_URL)
23+
else:
24+
client = _DummyClient()
25+
def get_translation(post: str) -> str:
26+
"""
27+
Translate a non-English post into English using the local Ollama model.
28+
(Copied from notebook.)
29+
"""
30+
context = (
31+
"You are a translation assistant. Translate the following text into natural English. "
32+
"Only output the translated text, with no explanations or commentary."
33+
)
34+
try:
35+
prompt = f"{context}\n\nText to translate:\n{post}"
36+
response = client.chat(
37+
model=MODEL_NAME,
38+
messages=[{"role": "user", "content": prompt}],
39+
)
40+
return response.message.content.strip()
41+
except Exception as e:
42+
return f"[Error: {type(e).__name__} - {e}]"
43+
def get_language(post: str) -> str:
44+
"""
45+
Detects the language of a given post using the LLM.
46+
Returns the name of the language in English (e.g. 'German', 'Spanish', 'Chinese', etc.)
47+
(Copied from notebook, with a small robustness tweak.)
48+
"""
49+
prompt = (
50+
"Identify the language of the following text. "
51+
"Respond with only the language name in English (for example, 'German', 'Spanish', 'Chinese'). "
52+
"Do not answer in the language itself.\n\n"
53+
f"Text:\n{post}"
54+
)
55+
try:
56+
response = client.chat(
57+
model=MODEL_NAME,
58+
messages=[{"role": "user", "content": prompt}],
59+
)
60+
if isinstance(response, dict):
61+
content = response.get("message", {}).get("content", "")
62+
else:
63+
content = getattr(getattr(response, "message", None), "content", "")
64+
return str(content).strip()
65+
except Exception as e:
66+
return f"[Error: {type(e).__name__} - {e}]"
67+
def query_llm_robust(post: str) -> tuple[bool, str]:
68+
"""
69+
A robust version of query_llm that safely handles unexpected model responses or errors.
70+
Ensures output is always in the correct format (bool, str).
71+
(Exactly your notebook code.)
72+
"""
73+
try:
74+
# Try language detection
75+
lang = get_language(post)
76+
# Validate language detection output
77+
if not isinstance(lang, str) or len(lang.strip()) == 0:
78+
# Model gave empty or invalid response
79+
return False, "[Invalid language detection output]"
80+
# Determine if English
81+
is_english = lang.strip().lower() in ["english", "en"]
82+
# If English, return original post
83+
if is_english:
84+
return True, post.strip()
85+
# If not English, attempt translation
86+
translation = get_translation(post)
87+
# Validate translation output
88+
if not isinstance(translation, str) or len(translation.strip()) == 0:
89+
return False, "[Invalid translation output]"
90+
return False, translation.strip()
91+
except Exception as e:
92+
return False, f"[Error: {type(e).__name__} - {e}]"

test/test_llm_experiment.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
from unittest.mock import patch
3+
from src.llm_experiment import query_llm_robust, client
4+
5+
6+
@patch.object(client, "chat")
7+
def test_unexpected_language(mock_chat):
8+
# we mock the model's response to return a random message
9+
mock_chat.return_value.message.content = "I don't understand your request"
10+
11+
# just check it returns a valid tuple and doesn't crash
12+
result = query_llm_robust("Hier ist dein erstes Beispiel.")
13+
assert isinstance(result, tuple)
14+
assert isinstance(result[0], bool)
15+
assert isinstance(result[1], str)
16+
17+
18+
@patch.object(client, "chat")
19+
def test_empty_response(mock_chat):
20+
mock_chat.return_value.message.content = ""
21+
result = query_llm_robust("Bonjour le monde")
22+
assert isinstance(result, tuple)
23+
assert result[0] is False
24+
25+
26+
@patch.object(client, "chat")
27+
def test_nonstring_response(mock_chat):
28+
mock_chat.return_value.message.content = {"text": "Hello"}
29+
result = query_llm_robust("Hola amigo")
30+
assert result[0] is False
31+
32+
33+
@patch.object(client, "chat", side_effect=Exception("Network error"))
34+
def test_model_exception(mock_chat):
35+
result = query_llm_robust("Ciao amico")
36+
assert result[0] is False
37+
38+
39+
@patch.object(client, "chat")
40+
def test_none_response(mock_chat):
41+
mock_chat.return_value.message.content = None
42+
result = query_llm_robust("こんにちは")
43+
assert result[0] is False
44+
assert "Invalid" in result[1] or "Error" in result[1]
45+
46+
47+
@patch.object(client, "chat")
48+
def test_very_long_response(mock_chat):
49+
mock_chat.return_value.message.content = "Hello" * 10000
50+
result = query_llm_robust("Привет")
51+
assert isinstance(result, tuple)
52+
assert len(result[1]) < 60000
53+
54+
55+
@patch.object(client, "chat")
56+
def test_gibberish_response(mock_chat):
57+
mock_chat.return_value.message.content = "�#@!∂ƒ©˙∆˚¬…æ≈ç√"
58+
result = query_llm_robust("안녕하세요")
59+
assert result[0] is False

0 commit comments

Comments
 (0)