Skip to content

Commit 9836cbe

Browse files
authored
Merge pull request #65 from AET-DevOps25/feat/38-integrate-learning-path-genai
Feat/38 integrate learning path genai closes #38
2 parents 89d01c8 + 4812ed8 commit 9836cbe

File tree

16 files changed

+307
-259
lines changed

16 files changed

+307
-259
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ MONGODB_EXPOSED_PORT=27017 # (default: 27017)
1414
MONGODB_DATABASE=skillforge_dev # Name of the database to create/use
1515
MONGODB_USERNAME=dev_user # MongoDB username (choose any)
1616
MONGODB_PASSWORD=dev_password # MongoDB password (choose any)
17+
MONGO_URL=mongodb://dev_user:dev_password@mongo:27017/skillforge-dev?authSource=admin
1718

1819
##########################
1920
# 🚦 Gateway Service
@@ -61,6 +62,8 @@ GENAI_APP_NAME=SkillForge GenAI # (default: SkillForge GenAI)
6162
GENAI_APP_VERSION=1.0.0 # (default: 1.0.0)
6263
UVICORN_WORKERS=2 # (default: 2)
6364
CORS_ALLOW_ORIGINS=* # (default: *)
65+
SERVER_HOST_GENAI=skillforge-genai
66+
SERVER_PORT_GENAI=8888
6467

6568
##########################
6669
# 🤖 LLM Provider (OpenAI or other)
@@ -84,7 +87,7 @@ WEAVIATE_EXPOSED_GRPC_PORT=50051 # (default: 50051)
8487
##########################
8588
CLIENT_HOST=client.localhost
8689
SERVER_HOST=server.localhost
87-
90+
GENAI_HOST=genai.localhost
8891
###############################################
8992
# ⚠️ Notes:
9093
# - Secrets like JWT_SECRET and OPENAI_API_KEY should NEVER be committed!

.github/workflows/build-and-test-server.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ jobs:
114114
env:
115115
SPRING_PROFILES_ACTIVE: test
116116
MONGO_URL: mongodb://localhost:27017/test
117+
SERVER_HOST_GENAI: localhost
118+
SERVER_PORT_GENAI: 8888
117119
run: ./gradlew build --no-daemon --scan
118120
- name: Post workflow summary
119121
if: always()

.github/workflows/deploy_to_aws.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,12 @@ jobs:
9696
9797
echo "CLIENT_HOST=client.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
9898
echo "SERVER_HOST=api.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
99+
echo "GENAI_HOST=genai.api.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
99100
echo "VITE_PUBLIC_API_URL=https://api.${{ vars.EC2_PUBLIC_IP }}.nip.io/api" >> .env
100101
echo "VITE_API_INTERNAL_HOST=server-gateway" >> .env
101102
echo "VITE_API_INTERNAL_PORT=8081" >> .env
103+
echo "SERVER_HOST_GENAI=skillforge-genai" >> .env
104+
echo "SERVER_PORT_GENAI=8888" >> .env
102105
103106
chmod 600 .env
104107
echo ".env file created ✅"

.github/workflows/manual_aws_deploy.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ jobs:
8585
8686
echo "CLIENT_HOST=client.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
8787
echo "SERVER_HOST=api.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
88+
echo "GENAI_HOST=genai.api.${{ vars.EC2_PUBLIC_IP }}.nip.io" >> .env
8889
echo "VITE_PUBLIC_API_URL=https://api.${{ vars.EC2_PUBLIC_IP }}.nip.io/api" >> .env
8990
echo "VITE_API_INTERNAL_HOST=server-gateway" >> .env
9091
echo "VITE_API_INTERNAL_PORT=8081" >> .env
92+
echo "SERVER_HOST_GENAI=skillforge-genai" >> .env
93+
echo "SERVER_PORT_GENAI=8888" >> .env
9194
9295
chmod 600 .env
9396
echo ".env file created ✅"
@@ -117,7 +120,7 @@ jobs:
117120
echo "Deployment complete! Access your services at:"
118121
echo "Client: https://client.${{ vars.EC2_PUBLIC_IP }}.nip.io"
119122
echo "API: https://api.${{ vars.EC2_PUBLIC_IP }}.nip.io"
120-
echo "GenAI: https://genai.ai.${{ vars.EC2_PUBLIC_IP }}.nip.io"
123+
echo "GenAI: https://genai.api.${{ vars.EC2_PUBLIC_IP }}.nip.io"
121124
echo "Check the status of your services with:"
122125
echo "docker compose -f docker-compose.prod.yml ps"
123126
echo "View logs with:"

docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ services:
114114
MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/skillforge-dev}
115115
JWT_SECRET: ${JWT_SECRET}
116116
JWT_EXPIRATION_MS: 86400000
117+
SERVER_HOST_GENAI: ${SERVER_HOST_GENAI:-skillforge-genai-service}
118+
SERVER_PORT_GENAI: ${SERVER_PORT_GENAI:-8888}
119+
117120
depends_on:
118121
mongo:
119122
condition: service_healthy

genai/src/main.py

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,26 @@
1919
from .services.embedding.schemas import EmbedRequest, EmbedResponse, QueryRequest, QueryResponse, DocumentResult
2020
from .services.embedding.weaviate_service import get_weaviate_client, ensure_schema_exists, DOCUMENT_CLASS_NAME
2121
from .services.llm import llm_service
22-
from .services.llm.schemas import GenerateRequest, GenerateResponse
23-
from .services.rag.schemas import CourseGenerationRequest, Course
24-
from .services.rag import course_generator
25-
from .utils.error_schema import ErrorResponse
22+
from .services.llm.schemas import GenerateRequest, GenerateResponse
23+
from .services.rag.schemas import CourseGenerationRequest, Course
24+
from .services.rag import course_generator
25+
from .utils.error_schema import ErrorResponse
2626
from .utils.handle_httpx_exception import handle_httpx_exception
2727

28+
2829
# --- Configuration ---
2930
load_dotenv()
3031
logger = logging.getLogger("skillforge.genai")
3132

3233
APP_PORT = int(os.getenv("GENAI_PORT", "8082"))
3334
APP_TITLE = os.getenv("GENAI_APP_NAME", "SkillForge GenAI Service")
3435
APP_VERSION = os.getenv("GENAI_APP_VERSION", "0.0.1")
35-
APP_DESCRIPTION = (
36-
"SkillForge GenAI Service provides endpoints for web crawling, "
37-
"chunking, embedding, semantic querying, and text generation using LLMs. "
38-
"Ideal for integrating vector search and AI-driven workflows."
39-
)
40-
API_PREFIX = "/api/v1"
36+
APP_DESCRIPTION = (
37+
"SkillForge GenAI Service provides endpoints for web crawling, "
38+
"chunking, embedding, semantic querying, and text generation using LLMs. "
39+
"Ideal for integrating vector search and AI-driven workflows."
40+
)
41+
API_PREFIX = "/api/v1"
4142
TAGS_METADATA = [
4243
{"name": "System", "description": "Health checks and system status."},
4344
{"name": "Crawler", "description": "Crawl and clean website content."},
@@ -247,20 +248,22 @@ async def generate_completion(request: GenerateRequest):
247248
logging.error(f"ERROR during text generation: {e}")
248249
raise HTTPException(status_code=500, detail=f"Failed to generate text: {str(e)}")
249250

250-
# ──────────────────────────────────────────────────────────────────────────
251-
# NEW – main RAG endpoint
252-
# ──────────────────────────────────────────────────────────────────────────
253-
@app.post(f"{API_PREFIX}/rag/generate-course", response_model=Course, tags=["rag"])
254-
async def generate_course(req: CourseGenerationRequest):
255-
"""
256-
• POST because generation is a side-effectful operation (non-idempotent).
257-
• Returns a fully-validated Course JSON ready for the course-service.
258-
"""
259-
try:
260-
return course_generator.generate_course(req)
261-
except Exception as e:
251+
252+
# ──────────────────────────────────────────────────────────────────────────
253+
# RAG endpoint
254+
# ──────────────────────────────────────────────────────────────────────────
255+
@app.post("/api/v1/rag/generate-course", response_model=Course, tags=["rag"])
256+
async def generate_course(req: CourseGenerationRequest):
257+
"""
258+
• POST because generation is a side-effectful operation (non-idempotent).
259+
• Returns a fully-validated Course JSON ready for the course-service.
260+
"""
261+
try:
262+
return course_generator.generate_course(req)
263+
except Exception as e:
262264
raise HTTPException(500, str(e)) from e
263265

266+
264267
# -------------------------------
265268
# --------- MAIN ----------------
266269
# -------------------------------

genai/src/services/embedding/embedder_service.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
from typing import List
88
import numpy as np
9-
from .schemas import QueryResponse, QueryRequest, DocumentResult
9+
1010

1111
logger = logging.getLogger("skillforge.genai.embedder_service")
1212

@@ -47,17 +47,7 @@ def embed_and_store_text(text: str, source_url: str) -> int:
4747

4848
return num_chunks
4949

50-
_embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
51-
52-
def embed_text(text: str) -> List[float]:
53-
"""Generate a single embedding vector from raw text."""
54-
return _embeddings_model.embed_query(text)
55-
56-
def cosine_similarity(v1: List[float], v2: List[float]) -> float:
57-
"""Simple cosine similarity between two vectors."""
58-
a = np.array(v1)
59-
b = np.array(v2)
60-
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
50+
from .schemas import QueryResponse, QueryRequest, DocumentResult # reuse existing pydantic model
6151

6252
def query_similar_chunks(query_text: str, limit: int = 3) -> QueryResponse:
6353
"""
@@ -76,3 +66,16 @@ def query_similar_chunks(query_text: str, limit: int = 3) -> QueryResponse:
7666
)
7767
docs = [DocumentResult(**d) for d in result["data"]["Get"][DOCUMENT_CLASS_NAME]]
7868
return QueryResponse(query=query_text, results=docs)
69+
70+
71+
_embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
72+
73+
def embed_text(text: str) -> List[float]:
74+
"""Generate a single embedding vector from raw text."""
75+
return _embeddings_model.embed_query(text)
76+
77+
def cosine_similarity(v1: List[float], v2: List[float]) -> float:
78+
"""Simple cosine similarity between two vectors."""
79+
a = np.array(v1)
80+
b = np.array(v2)
81+
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
Lines changed: 53 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,126 +1,120 @@
1+
# genai/src/services/llm/llm_service.py
12
import os
2-
from langchain_openai import ChatOpenAI
33
import json
44
import logging
5-
from langchain_community.llms import FakeListLLM
6-
from langchain_core.language_models.base import BaseLanguageModel
75
from typing import List, Type, TypeVar
6+
87
from pydantic import BaseModel, ValidationError
8+
from langchain_openai import ChatOpenAI
9+
from langchain_community.llms import FakeListLLM
10+
from langchain_core.language_models.base import BaseLanguageModel
911

1012
logger = logging.getLogger(__name__)
13+
1114
T = TypeVar("T", bound=BaseModel)
1215

16+
# ──────────────────────────────────────────────────────────────────────────
17+
# LLM factory
18+
# ──────────────────────────────────────────────────────────────────────────
1319

1420
def llm_factory() -> BaseLanguageModel:
15-
"""
16-
Factory function to create and return an LLM instance based on the provider
17-
specified in the environment variables.
18-
Supports OpenAI, OpenAI-compatible (local/llmstudio), and dummy models.
19-
"""
21+
"""Return a singleton LangChain LLM according to $LLM_PROVIDER."""
2022
provider = os.getenv("LLM_PROVIDER", "dummy").lower()
2123
logger.info(f"--- Creating LLM for provider: {provider} ---")
2224

2325
if provider in ("openai", "llmstudio", "local"):
24-
# Get API base and key from env
2526
openai_api_key = os.getenv("OPENAI_API_KEY", "sk-xxx-dummy-key")
2627
openai_api_base = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
27-
2828
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
2929
return ChatOpenAI(
3030
model=model,
3131
temperature=0.7,
3232
openai_api_key=openai_api_key,
33-
openai_api_base=openai_api_base
33+
openai_api_base=openai_api_base,
3434
)
35-
36-
elif provider == "dummy":
35+
36+
if provider == "dummy":
3737
responses = [
3838
"The first summary from the dummy LLM is about procedural languages.",
3939
"The second summary is about object-oriented programming.",
4040
"This is a fallback response.",
4141
]
4242
return FakeListLLM(responses=responses)
4343

44-
else:
45-
raise ValueError(f"Currently Unsupported LLM provider: {provider}")
44+
raise ValueError(f"Unsupported LLM provider: {provider}")
45+
4646

4747
LLM_SINGLETON = llm_factory()
4848

49+
# ──────────────────────────────────────────────────────────────────────────
50+
# Convenience helpers
51+
# ──────────────────────────────────────────────────────────────────────────
52+
4953
def generate_text(prompt: str) -> str:
50-
"""
51-
Generates a text completion for a given prompt using the configured LLM.
52-
"""
53-
# 1. Get the correct LLM instance from our factory
54+
"""Simple text completion (legacy helper)."""
5455
llm = LLM_SINGLETON
5556

56-
# if we using local LLM, we need to append "/no_think" in case the model is a thinking model
57-
if os.getenv("LLM_PROVIDER", "dummy").lower() == "llmstudio" and hasattr(llm, 'model_name'):
58-
prompt += "/no_think"
59-
60-
# 2. Invoke the LLM with the prompt
57+
if os.getenv("LLM_PROVIDER", "dummy").lower() == "llmstudio" and hasattr(llm, "model_name"):
58+
prompt += "/no_think"
59+
6160
response = llm.invoke(prompt)
61+
return response.content if hasattr(response, "content") else response
62+
63+
64+
def generate_structured(messages: List[dict], schema: Type[T], *, max_retries: int = 3) -> T:
65+
"""Return a Pydantic object *schema* regardless of the underlying provider.
6266
63-
# 3. The response object's structure can vary slightly by model.
64-
# For Chat models, the text is in the .content attribute.
65-
# For standard LLMs (like our FakeListLLM), it's the string itself.
66-
if hasattr(response, 'content'):
67-
return response.content
68-
else:
69-
return response
70-
71-
72-
def generate_structured(
73-
messages: List[dict],
74-
schema: Type[T],
75-
*,
76-
max_retries: int = 3,
77-
) -> T:
78-
"""Return a Pydantic object regardless of provider (OpenAI JSON-mode or fallback)."""
67+
1. For $LLM_PROVIDER==openai we use the native `beta.chat.completions.parse` API.
68+
2. Otherwise we fall back to strict JSON prompting and `model_validate_json()`.
69+
"""
7970
provider = os.getenv("LLM_PROVIDER", "dummy").lower()
8071

81-
# 1) OpenAI native JSON mode
72+
# ── 1. Native OpenAI JSON mode ───────────────────────────────────────
8273
if provider == "openai":
8374
try:
84-
from openai import OpenAI
75+
from openai import OpenAI # local import to avoid hard dep for other providers
76+
8577
client = OpenAI(
8678
api_key=os.getenv("OPENAI_API_KEY"),
8779
base_url=os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"),
8880
)
89-
resp = client.beta.chat.completions.parse(
81+
response = client.beta.chat.completions.parse(
9082
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
9183
messages=messages,
9284
response_format=schema,
9385
)
94-
return resp.choices[0].message.parsed # type: ignore[arg-type]
86+
return response.choices[0].message.parsed # type: ignore[arg-type]
9587
except Exception as e:
9688
logger.warning(f"OpenAI structured parse failed – falling back: {e}")
9789

98-
# 2) Generic JSON-string fallback
90+
# ── 2. Generic JSON-string fallback for all other models ─────────────
9991
system_json_guard = {
10092
"role": "system",
10193
"content": (
102-
"Return ONLY valid JSON matching this schema:\n"
103-
+ json.dumps(schema.model_json_schema())
94+
"You are a JSON-only assistant. Produce **only** valid JSON that conforms to "
95+
"this schema (no markdown, no explanations):\n" + json.dumps(schema.model_json_schema())
10496
),
10597
}
98+
10699
convo: List[dict] = [system_json_guard] + messages
107-
llm = LLM_SINGLETON
108100

101+
llm = LLM_SINGLETON
109102
for attempt in range(1, max_retries + 1):
110103
raw = llm.invoke(convo)
111-
text = raw.content if hasattr(raw, "content") else raw
104+
text = raw.content if hasattr(raw, "content") else raw # Chat vs non-chat
112105
try:
113106
return schema.model_validate_json(text)
114107
except ValidationError as e:
115108
logger.warning(
116-
f"Structured output validation failed ({attempt}/{max_retries}): {e}"
109+
f"Structured output validation failed (try {attempt}/{max_retries}): {e}"\
117110
)
118-
convo += [
119-
{"role": "assistant", "content": text},
120-
{
121-
"role": "user",
122-
"content": "❌ JSON invalid. Send ONLY fixed JSON.",
123-
},
124-
]
125-
126-
raise ValueError("Could not obtain valid structured output")
111+
convo.append({"role": "assistant", "content": text})
112+
convo.append({
113+
"role": "user",
114+
"content": (
115+
"❌ JSON was invalid: " + str(e.errors()) +
116+
"\nPlease resend ONLY the corrected JSON (no extraneous text)."
117+
),
118+
})
119+
120+
raise ValueError("Failed to get valid structured output after retries")

0 commit comments

Comments
 (0)