Skip to content

Commit 0550dc0

Browse files
committed
feat(dual-track): implement Phase 1 architecture with production multi-stage Dockerfile, docker-compose, Fly.io & Railway deploy configs, and standalone CLI tool for automated batch pipelines
1 parent 5ede140 commit 0550dc0

6 files changed

Lines changed: 380 additions & 0 deletions

File tree

.dockerignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Git & CI/CD
2+
.git
3+
.github
4+
.gitignore
5+
6+
# Python Bytecode & Cache
7+
__pycache__
8+
*.py[cod]
9+
*$py.class
10+
.pytest_cache
11+
.cache
12+
13+
# Environment & Local Secrets
14+
.env
15+
.env.local
16+
*.log
17+
18+
# Local Persistent Databases & Artifacts
19+
corpusld_store.db
20+
corpusld_store.db-wal
21+
corpusld_store.db-shm
22+
qdrant_db/
23+
uploads/
24+
benchmark_results/
25+
benchmark_corpus/
26+
.tmp.*
27+
.tools/
28+
29+
# Virtual Environments
30+
venv/
31+
.venv/
32+
env/

cli.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import argparse
2+
import json
3+
import os
4+
import sys
5+
import time
6+
from typing import List, Optional
7+
8+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9+
10+
from config import Config
11+
from json_ld_extractor import (
12+
extract_json_ld_agentic_rag,
13+
validate_json_ld_rich_results,
14+
get_clean_schema_org_jsonld,
15+
export_to_turtle_rdf,
16+
export_to_json_ld_graph,
17+
generate_html_head_package,
18+
calculate_graph_health_metrics,
19+
)
20+
from services.parser import parse_document
21+
22+
23+
def _print_banner():
24+
print("=" * 70)
25+
print("[CorpusLD CLI] Dual-Layer Academic Knowledge Extraction Engine")
26+
print("=" * 70)
27+
28+
29+
def cmd_extract(args):
30+
"""Extract a single PDF document into structured Linked Data."""
31+
pdf_path = args.input
32+
if not os.path.exists(pdf_path):
33+
print(f"[-] Error: File not found at '{pdf_path}'")
34+
sys.exit(1)
35+
36+
file_name = os.path.basename(pdf_path)
37+
print(f"[*] Parsing document: {file_name} (parser: {args.parser})...")
38+
39+
t_start = time.time()
40+
chunks = parse_document(pdf_path, file_name, parser_choice=args.parser)
41+
print(f"[+] Extracted {len(chunks)} text/table chunks in {time.time() - t_start:.2f}s")
42+
43+
def cli_logger(msg: str):
44+
print(f" {msg}")
45+
46+
print(f"[*] Running Dual-Layer Extraction (Provider: {args.provider}, Model: {args.model or 'default'})...")
47+
t_ext = time.time()
48+
res = extract_json_ld_agentic_rag(
49+
file_name=file_name,
50+
chunks=chunks,
51+
llm_provider=args.provider,
52+
llm_model=args.model,
53+
api_key=args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("OPENAI_API_KEY") or os.getenv("GROQ_API_KEY"),
54+
base_url=args.base_url,
55+
progress_callback=cli_logger
56+
)
57+
print(f"[+] Extraction completed in {time.time() - t_ext:.2f}s")
58+
59+
# Output formatting
60+
out_format = (args.format or "jsonld").lower()
61+
out_path = args.output
62+
if not out_path:
63+
base, _ = os.path.splitext(pdf_path)
64+
ext_map = {"jsonld": ".jsonld", "turtle": ".ttl", "graph": ".graph.jsonld", "html": ".head.html"}
65+
out_path = f"{base}{ext_map.get(out_format, '.jsonld')}"
66+
67+
if out_format == "turtle" or out_format == "ttl":
68+
content = export_to_turtle_rdf(res)
69+
with open(out_path, "w", encoding="utf-8") as f:
70+
f.write(content)
71+
elif out_format == "html":
72+
content = generate_html_head_package(res)
73+
with open(out_path, "w", encoding="utf-8") as f:
74+
f.write(content)
75+
elif out_format == "graph":
76+
graph_data = export_to_json_ld_graph(res)
77+
with open(out_path, "w", encoding="utf-8") as f:
78+
json.dump(graph_data, f, indent=2, ensure_ascii=False)
79+
else:
80+
clean_json = get_clean_schema_org_jsonld(res)
81+
with open(out_path, "w", encoding="utf-8") as f:
82+
json.dump(clean_json, f, indent=2, ensure_ascii=False)
83+
84+
print(f"[+] Output successfully saved to: {out_path}")
85+
86+
# Validation report
87+
if args.validate:
88+
print("\n[*] Running Adversarial & Knowledge Graph Validation...")
89+
val_res = validate_json_ld_rich_results(res)
90+
print(f" Score: {val_res.get('score', 0)}/100")
91+
print(f" Schema Score: {val_res.get('schema_score', 0)} | KG Integrity: {val_res.get('kg_integrity_score', 0)}")
92+
for chk in val_res.get("checks", []):
93+
print(f" [{chk.get('status')}] {chk.get('title')}: {chk.get('desc')}")
94+
95+
96+
def cmd_validate(args):
97+
"""Validate an existing extracted JSON-LD document."""
98+
json_path = args.input
99+
if not os.path.exists(json_path):
100+
print(f"[-] Error: File not found at '{json_path}'")
101+
sys.exit(1)
102+
103+
with open(json_path, "r", encoding="utf-8") as f:
104+
data = json.load(f)
105+
106+
print(f"[*] Validating JSON-LD document: {json_path}")
107+
val_res = validate_json_ld_rich_results(data)
108+
print(f"\n[+] Validation Results:")
109+
print(f" Total Score: {val_res.get('score', 0)}/100")
110+
print(f" Resolution: {val_res.get('resolution', '')}")
111+
print(f" Recommendation: {val_res.get('recommendation', '')}\n")
112+
113+
for chk in val_res.get("checks", []):
114+
print(f" [{chk.get('status')}] {chk.get('title')}: {chk.get('desc')}")
115+
116+
if val_res.get("kg_checks"):
117+
print("\n[*] Deep Knowledge Graph Checks:")
118+
for kchk in val_res.get("kg_checks", []):
119+
print(f" [{kchk.get('status')}] {kchk.get('title')}: {kchk.get('details')}")
120+
121+
122+
def cmd_batch(args):
123+
"""Batch process an entire folder of PDF documents."""
124+
input_dir = args.input_dir
125+
output_dir = args.output_dir or os.path.join(input_dir, "extracted_corpus")
126+
os.makedirs(output_dir, exist_ok=True)
127+
128+
pdf_files = [f for f in os.listdir(input_dir) if f.lower().endswith(".pdf")]
129+
if not pdf_files:
130+
print(f"[!] No PDF files found in '{input_dir}'")
131+
return
132+
133+
print(f"[*] Found {len(pdf_files)} PDF documents in '{input_dir}'. Starting batch processing...")
134+
for idx, f in enumerate(pdf_files, 1):
135+
print(f"\n[{idx}/{len(pdf_files)}] Processing: {f}")
136+
full_pdf = os.path.join(input_dir, f)
137+
base_name, _ = os.path.splitext(f)
138+
out_target = os.path.join(output_dir, f"{base_name}.jsonld")
139+
try:
140+
chunks = parse_document(full_pdf, f, parser_choice=args.parser)
141+
res = extract_json_ld_agentic_rag(
142+
file_name=f,
143+
chunks=chunks,
144+
llm_provider=args.provider,
145+
llm_model=args.model,
146+
api_key=args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("OPENAI_API_KEY") or os.getenv("GROQ_API_KEY"),
147+
base_url=args.base_url
148+
)
149+
with open(out_target, "w", encoding="utf-8") as out_f:
150+
json.dump(get_clean_schema_org_jsonld(res), out_f, indent=2, ensure_ascii=False)
151+
print(f" [+] Saved: {out_target}")
152+
except Exception as e:
153+
print(f" [-] Failed to process {f}: {e}")
154+
155+
print(f"\n[+] Batch extraction finished! All outputs saved to '{output_dir}'.")
156+
157+
158+
def main():
159+
_print_banner()
160+
parser = argparse.ArgumentParser(description="CorpusLD - Dual-Layer Academic Knowledge Extraction CLI")
161+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
162+
163+
# Extract Command
164+
extract_p = subparsers.add_parser("extract", help="Extract single PDF document")
165+
extract_p.add_argument("input", help="Path to input PDF file")
166+
extract_p.add_argument("-o", "--output", help="Output file destination path")
167+
extract_p.add_argument("-f", "--format", choices=["jsonld", "turtle", "graph", "html"], default="jsonld", help="Output format")
168+
extract_p.add_argument("-p", "--provider", default="ollama", choices=["ollama", "gemini", "groq", "openai", "deepseek", "custom"], help="LLM inference provider")
169+
extract_p.add_argument("-m", "--model", help="Specific LLM model name")
170+
extract_p.add_argument("-k", "--api-key", help="Provider API key")
171+
extract_p.add_argument("-u", "--base-url", help="Custom OpenAI-compatible base URL")
172+
extract_p.add_argument("--parser", default="pypdf", choices=["pypdf", "llamaparse", "unstructured", "hybrid"], help="PDF ingestion parser")
173+
extract_p.add_argument("--validate", action="store_true", help="Run rich results validation after extraction")
174+
175+
# Batch Command
176+
batch_p = subparsers.add_parser("batch", help="Batch extract directory of PDF documents")
177+
batch_p.add_argument("input_dir", help="Directory containing PDF files")
178+
batch_p.add_argument("-o", "--output-dir", help="Destination directory for JSON-LD files")
179+
batch_p.add_argument("-p", "--provider", default="ollama", choices=["ollama", "gemini", "groq", "openai", "deepseek", "custom"], help="LLM inference provider")
180+
batch_p.add_argument("-m", "--model", help="Specific LLM model name")
181+
batch_p.add_argument("-k", "--api-key", help="Provider API key")
182+
batch_p.add_argument("-u", "--base-url", help="Custom OpenAI-compatible base URL")
183+
batch_p.add_argument("--parser", default="pypdf", choices=["pypdf", "llamaparse", "unstructured", "hybrid"], help="PDF ingestion parser")
184+
185+
# Validate Command
186+
val_p = subparsers.add_parser("validate", help="Validate existing JSON-LD file")
187+
val_p.add_argument("input", help="Path to JSON-LD file")
188+
189+
args = parser.parse_args()
190+
if args.command == "extract":
191+
cmd_extract(args)
192+
elif args.command == "batch":
193+
cmd_batch(args)
194+
elif args.command == "validate":
195+
cmd_validate(args)
196+
else:
197+
parser.print_help()
198+
199+
200+
if __name__ == "__main__":
201+
main()

deploy/fly.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Fly.io deployment configuration for CorpusLD Studio (Hosted Web Track)
2+
app = "corpusld-studio"
3+
primary_region = "sin"
4+
5+
[build]
6+
dockerfile = "docker/Dockerfile"
7+
8+
[http_service]
9+
internal_port = 8000
10+
force_https = true
11+
auto_stop_machines = true
12+
auto_start_machines = true
13+
min_machines_running = 1
14+
15+
[checks]
16+
[checks.health]
17+
port = 8000
18+
type = "http"
19+
interval = "15s"
20+
timeout = "5s"
21+
path = "/api/health"
22+
23+
[[vm]]
24+
memory = "1gb"
25+
cpu_kind = "shared"
26+
cpus = 1

deploy/railway.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://railway.app/railway.schema.json",
3+
"build": {
4+
"builder": "DOCKERFILE",
5+
"dockerfilePath": "docker/Dockerfile"
6+
},
7+
"deploy": {
8+
"startCommand": "uvicorn server:app --host 0.0.0.0 --port $PORT",
9+
"healthcheckPath": "/api/health",
10+
"healthcheckTimeout": 60,
11+
"restartPolicyType": "ON_FAILURE",
12+
"restartPolicyMaxRetries": 5
13+
}
14+
}

docker/Dockerfile

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ==============================================================================
2+
# CorpusLD Studio - Production Multi-Stage Dockerfile
3+
# ==============================================================================
4+
5+
FROM python:3.11-slim AS builder
6+
7+
WORKDIR /build
8+
9+
RUN apt-get update && apt-get install -y --no-install-recommends \
10+
build-essential \
11+
curl \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
COPY requirements.txt requirements-lock.txt ./
15+
RUN pip install --no-cache-dir --user -r requirements-lock.txt
16+
17+
# Final Runtime Image
18+
FROM python:3.11-slim AS runtime
19+
20+
WORKDIR /app
21+
22+
# Install minimal OS dependencies for PDF parsing & HTTP healthchecks
23+
RUN apt-get update && apt-get install -y --no-install-recommends \
24+
curl \
25+
ca-certificates \
26+
&& rm -rf /var/lib/apt/lists/*
27+
28+
# Copy installed Python packages from builder stage
29+
COPY --from=builder /root/.local /root/.local
30+
ENV PATH=/root/.local/bin:$PATH
31+
ENV PYTHONUNBUFFERED=1
32+
ENV PYTHONDONTWRITEBYTECODE=1
33+
34+
# Copy application source code
35+
COPY config.py server.py cli.py ./
36+
COPY json_ld_extractor/ ./json_ld_extractor/
37+
COPY services/ ./services/
38+
COPY routes/ ./routes/
39+
COPY frontend/ ./frontend/
40+
41+
# Create persistent storage directories
42+
RUN mkdir -p /app/uploads /app/qdrant_db
43+
44+
EXPOSE 8000
45+
46+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
47+
CMD curl -f http://localhost:8000/api/health || exit 1
48+
49+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]

docker/docker-compose.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
version: '3.8'
2+
3+
services:
4+
corpusld:
5+
build:
6+
context: ..
7+
dockerfile: docker/Dockerfile
8+
container_name: corpusld_app
9+
restart: unless-stopped
10+
ports:
11+
- "8000:8000"
12+
environment:
13+
- QDRANT_URL=http://qdrant:6333
14+
- QDRANT_COLLECTION_NAME=corpusld_workspace
15+
- OLLAMA_MODEL_NAME=qwen2.5:3b
16+
- GEMINI_MODEL_NAME=gemini-2.5-flash-lite
17+
- EMBEDDING_MODEL_NAME=ibm-granite/granite-embedding-107m-multilingual
18+
- CORS_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
19+
volumes:
20+
- corpusld_data:/app/uploads
21+
- corpusld_db:/app/corpusld_store.db
22+
depends_on:
23+
- qdrant
24+
networks:
25+
- corpusld_net
26+
27+
qdrant:
28+
image: qdrant/qdrant:v1.13.2
29+
container_name: corpusld_qdrant
30+
restart: unless-stopped
31+
ports:
32+
- "6333:6333"
33+
volumes:
34+
- qdrant_data:/qdrant/storage
35+
networks:
36+
- corpusld_net
37+
38+
# Optional local Ollama inference service (uncomment for full offline on-prem deployment)
39+
# ollama:
40+
# image: ollama/ollama:latest
41+
# container_name: corpusld_ollama
42+
# restart: unless-stopped
43+
# ports:
44+
# - "11434:11434"
45+
# volumes:
46+
# - ollama_models:/root/.ollama
47+
# networks:
48+
# - corpusld_net
49+
50+
volumes:
51+
corpusld_data:
52+
corpusld_db:
53+
qdrant_data:
54+
# ollama_models:
55+
56+
networks:
57+
corpusld_net:
58+
driver: bridge

0 commit comments

Comments
 (0)