vishap-analyzer 5.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/__init__.py ADDED
File without changes
src/api/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # -*- coding: utf-8 -*-
2
+ """API helpers: auth, rate limit."""
src/api/auth.py ADDED
@@ -0,0 +1,41 @@
1
+ # -*- coding: utf-8 -*-
2
+ """API-key authentication (dogfood P0 fix).
3
+
4
+ Dogfood finding: POST /analyze, /history/clear, /mode were open
5
+ to any client on 0.0.0.0:5001.
6
+
7
+ Behavior:
8
+ - If CONVEYOR_API_KEY is set in .env -> require X-API-Key header.
9
+ - If CONVEYOR_API_KEY is NOT set -> API works ONLY on 127.0.0.1
10
+ (see main.py uvicorn.run binding).
11
+ """
12
+ import os
13
+ from fastapi import Header, HTTPException, status
14
+
15
+ API_KEY = os.getenv("CONVEYOR_API_KEY", "").strip()
16
+
17
+
18
+ async def verify_api_key(x_api_key: str = Header(default="")) -> None:
19
+ """FastAPI dependency: verify X-API-Key header.
20
+
21
+ If API_KEY is empty (dev mode), allow all (main.py binds to 127.0.0.1).
22
+ If API_KEY is set, require exact match.
23
+ """
24
+ if not API_KEY:
25
+ return
26
+
27
+ if not x_api_key:
28
+ raise HTTPException(
29
+ status_code=status.HTTP_401_UNAUTHORIZED,
30
+ detail="X-API-Key header required",
31
+ )
32
+ if x_api_key != API_KEY:
33
+ raise HTTPException(
34
+ status_code=status.HTTP_403_FORBIDDEN,
35
+ detail="Invalid API key",
36
+ )
37
+
38
+
39
+ def is_auth_enabled() -> bool:
40
+ """True if CONVEYOR_API_KEY is set."""
41
+ return bool(API_KEY)
src/api/rate_limit.py ADDED
@@ -0,0 +1,46 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Simple in-memory rate limiter (dogfood P0 fix).
3
+
4
+ Dogfood finding: no rate limiting on /analyze -> any client could
5
+ exhaust DeepSeek API budget.
6
+
7
+ Limits (per IP):
8
+ - 10 requests / minute for /analyze, /analyze/batch
9
+ - 30 requests / minute for /history, /mode
10
+ """
11
+ import time
12
+ from collections import defaultdict, deque
13
+ from fastapi import Request, HTTPException, status
14
+
15
+
16
+ _BUCKETS = defaultdict(deque)
17
+
18
+ LIMIT_ANALYZE = 10
19
+ LIMIT_READ = 30
20
+ WINDOW_SECONDS = 60
21
+
22
+
23
+ def _check(key: str, limit: int) -> None:
24
+ now = time.time()
25
+ bucket = _BUCKETS[key]
26
+
27
+ while bucket and bucket[0] < now - WINDOW_SECONDS:
28
+ bucket.popleft()
29
+
30
+ if len(bucket) >= limit:
31
+ raise HTTPException(
32
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
33
+ detail="Rate limit exceeded: {} req / {}s".format(limit, WINDOW_SECONDS),
34
+ )
35
+
36
+ bucket.append(now)
37
+
38
+
39
+ async def rate_limit_analyze(request: Request) -> None:
40
+ ip = request.client.host if request.client else "unknown"
41
+ _check("analyze:" + ip, LIMIT_ANALYZE)
42
+
43
+
44
+ async def rate_limit_read(request: Request) -> None:
45
+ ip = request.client.host if request.client else "unknown"
46
+ _check("read:" + ip, LIMIT_READ)
src/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Vishap CLI entry point.
3
+
4
+ Usage:
5
+ vishap analyze <file>
6
+ vishap info
7
+ vishap --version
8
+ """
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from src.config import APP_NAME
14
+ from src.pipeline.orchestrator import run_pipeline
15
+
16
+
17
+ def cmd_analyze(args):
18
+ """Analyze a file."""
19
+ path = Path(args.input)
20
+ if not path.exists():
21
+ print(f"Error: {path} not found", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+ material = path.read_text(encoding="utf-8", errors="replace")
25
+ result = run_pipeline(material, path.name, verbose=not args.quiet)
26
+
27
+ md = result.get("final_markdown", "")
28
+ audit = result.get("audit", {})
29
+
30
+ if args.output:
31
+ Path(args.output).write_text(md, encoding="utf-8")
32
+ print(f"Written: {args.output}")
33
+ else:
34
+ print(md)
35
+ print()
36
+ print(f"audit_id: {audit.get('audit_id')}")
37
+ print(f"material_hash: {audit.get('material_hash')}")
38
+ print(f"verdict: {audit.get('critique_verdict')} / {audit.get('risk_verdict')}")
39
+
40
+
41
+ def cmd_info(args):
42
+ """Show info."""
43
+ print(f"{APP_NAME} V5.8 - 6-stage LLM pipeline")
44
+ print("Pipeline: Stage 0-5 (Orchestrator > Scanner > Analyst > Researcher > Synthesizer > Transmitter)")
45
+ print("Features: audit trail, PII redaction, compliance flags, SAST/SCA, SARIF, retry, fallback")
46
+ print()
47
+ print("Commands:")
48
+ print(" vishap analyze <file> - analyze a file")
49
+ print(" vishap info - this help")
50
+ print()
51
+ print("API: python -m src.main - FastAPI on :5001")
52
+ print("UI: streamlit run src/ui/streamlit_app.py - Streamlit on :8501")
53
+
54
+
55
+ def main():
56
+ ap = argparse.ArgumentParser(prog="vishap", description=f"{APP_NAME} V5.8")
57
+ ap.add_argument("--version", action="version", version=f"{APP_NAME} V5.8")
58
+
59
+ sub = ap.add_subparsers(dest="command")
60
+
61
+ p_analyze = sub.add_parser("analyze", help="Analyze a file")
62
+ p_analyze.add_argument("input", help="Path to file")
63
+ p_analyze.add_argument("--quiet", action="store_true")
64
+ p_analyze.add_argument("--output", "-o", help="Output markdown path")
65
+ p_analyze.set_defaults(func=cmd_analyze)
66
+
67
+ p_info = sub.add_parser("info", help="Show info")
68
+ p_info.set_defaults(func=cmd_info)
69
+
70
+ args = ap.parse_args()
71
+ if not args.command:
72
+ ap.print_help()
73
+ sys.exit(1)
74
+
75
+ args.func(args)
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
src/config.py ADDED
@@ -0,0 +1,39 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Конфигурация Vishap.
4
+ """
5
+ import os
6
+ from pathlib import Path
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ # === Пути ===
12
+ ROOT_DIR = Path(__file__).parent.parent
13
+ DATA_DIR = ROOT_DIR / "data"
14
+ PROMPTS_DIR = ROOT_DIR / "vishap"
15
+ TOOLS_DIR = ROOT_DIR / "src" / "tools"
16
+
17
+ # === DeepSeek API ===
18
+ DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "")
19
+ DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"
20
+ DEEPSEEK_MODEL = "deepseek-chat"
21
+
22
+ # === Ollama (локально) ===
23
+ OLLAMA_MODEL = "qwen2.5:7b"
24
+ OLLAMA_URL = "http://localhost:11434"
25
+
26
+ # === Режим LLM ===
27
+ MODE = "auto"
28
+
29
+ # === Генерация ===
30
+ TEMPERATURE = 0.0
31
+ TEMPERATURE_CREATIVE = 0.3
32
+ MAX_TOKENS = 8000
33
+
34
+ # === Сервер ===
35
+ PORT = 5001
36
+ APP_NAME = "Vishap"
37
+
38
+ # === Данные ===
39
+ DATA_DIR.mkdir(exist_ok=True)
src/llm/__init__.py ADDED
File without changes
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Асинхронный клиент LLM с fallback:
4
+ cloud → DeepSeek API
5
+ local → Ollama
6
+ auto → облако, при ошибке → локально
7
+ """
8
+ from src.storage.mode import get_mode
9
+ from src.llm import async_deepseek_client, async_ollama_client
10
+
11
+
12
+ async def async_chat(system: str, user: str,
13
+ temperature: float = 0.0,
14
+ max_tokens: int = 2000) -> dict:
15
+ """Асинхронный вызов LLM. Аналог chat() с fallback."""
16
+ mode = get_mode()
17
+
18
+ if mode == "local":
19
+ result = await async_ollama_client.chat(system, user, temperature)
20
+ result["mode"] = "local"
21
+ return result
22
+
23
+ if mode == "cloud":
24
+ result = await async_deepseek_client.chat(system, user, temperature, max_tokens)
25
+ result["mode"] = "cloud"
26
+ return result
27
+
28
+ # auto
29
+ try:
30
+ result = await async_deepseek_client.chat(system, user, temperature, max_tokens)
31
+ result["mode"] = "cloud"
32
+ return result
33
+ except Exception as e:
34
+ print(f"[Vishap] DeepSeek недоступен: {e}. Fallback → локально.")
35
+ try:
36
+ result = await async_ollama_client.chat(system, user, temperature)
37
+ result["mode"] = "local"
38
+ return result
39
+ except Exception as e2:
40
+ return {
41
+ "text": f"[Ошибка LLM] cloud: {e}; local: {e2}",
42
+ "tokens_in": 0, "tokens_out": 0,
43
+ "model": "none", "mode": "error",
44
+ }
@@ -0,0 +1,54 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Асинхронный клиент DeepSeek API (через openai SDK)."""
3
+ import logging
4
+
5
+ from openai import AsyncOpenAI, APIConnectionError, RateLimitError, APIStatusError
6
+ from tenacity import (
7
+ retry,
8
+ stop_after_attempt,
9
+ wait_exponential,
10
+ retry_if_exception_type,
11
+ before_sleep_log,
12
+ )
13
+
14
+ from src.config import DEEPSEEK_API_KEY, DEEPSEEK_MODEL
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def get_async_client() -> AsyncOpenAI:
20
+ if not DEEPSEEK_API_KEY:
21
+ raise ValueError("DEEPSEEK_API_KEY не задан в .env")
22
+ return AsyncOpenAI(
23
+ api_key=DEEPSEEK_API_KEY,
24
+ base_url="https://api.deepseek.com/v1",
25
+ )
26
+
27
+
28
+ @retry(
29
+ stop=stop_after_attempt(3),
30
+ wait=wait_exponential(multiplier=1, min=2, max=30),
31
+ retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIStatusError)),
32
+ before_sleep=before_sleep_log(logger, logging.WARNING),
33
+ reraise=True,
34
+ )
35
+ async def chat(system: str, user: str,
36
+ temperature: float = 0.0,
37
+ max_tokens: int = 2000) -> dict:
38
+ """Асинхронный вызов DeepSeek. Возвращает {text, tokens_in, tokens_out, model}."""
39
+ client = get_async_client()
40
+ response = await client.chat.completions.create(
41
+ model=DEEPSEEK_MODEL,
42
+ messages=[
43
+ {"role": "system", "content": system},
44
+ {"role": "user", "content": user},
45
+ ],
46
+ temperature=temperature,
47
+ max_tokens=max_tokens,
48
+ )
49
+ return {
50
+ "text": response.choices[0].message.content,
51
+ "tokens_in": response.usage.prompt_tokens,
52
+ "tokens_out": response.usage.completion_tokens,
53
+ "model": DEEPSEEK_MODEL,
54
+ }
@@ -0,0 +1,24 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Асинхронный клиент Ollama (локальный LLM)."""
3
+ import ollama
4
+ from src.config import OLLAMA_MODEL
5
+
6
+
7
+ async def chat(system: str, user: str,
8
+ temperature: float = 0.0) -> dict:
9
+ """Асинхронный вызов Ollama. Возвращает {text, tokens_in, tokens_out, model}."""
10
+ client = ollama.AsyncClient()
11
+ response = await client.chat(
12
+ model=OLLAMA_MODEL,
13
+ messages=[
14
+ {"role": "system", "content": system},
15
+ {"role": "user", "content": user},
16
+ ],
17
+ options={"temperature": temperature},
18
+ )
19
+ return {
20
+ "text": response["message"]["content"],
21
+ "tokens_in": 0,
22
+ "tokens_out": 0,
23
+ "model": OLLAMA_MODEL,
24
+ }
src/llm/client.py ADDED
@@ -0,0 +1,44 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Единый клиент LLM с fallback:
4
+ cloud → DeepSeek API
5
+ local → Ollama
6
+ auto → облако, при ошибке → локально
7
+ """
8
+ from src.storage.mode import get_mode
9
+ from src.llm import deepseek_client, ollama_client
10
+
11
+
12
+ def chat(system: str, user: str,
13
+ temperature: float = 0.0,
14
+ max_tokens: int = 2000) -> dict:
15
+ """Возвращает {text, tokens_in, tokens_out, model, mode}."""
16
+ mode = get_mode()
17
+
18
+ if mode == "local":
19
+ result = ollama_client.chat(system, user, temperature)
20
+ result["mode"] = "local"
21
+ return result
22
+
23
+ if mode == "cloud":
24
+ result = deepseek_client.chat(system, user, temperature, max_tokens)
25
+ result["mode"] = "cloud"
26
+ return result
27
+
28
+ # auto
29
+ try:
30
+ result = deepseek_client.chat(system, user, temperature, max_tokens)
31
+ result["mode"] = "cloud"
32
+ return result
33
+ except Exception as e:
34
+ print(f"[Vishap] DeepSeek недоступен: {e}. Fallback → локально.")
35
+ try:
36
+ result = ollama_client.chat(system, user, temperature)
37
+ result["mode"] = "local"
38
+ return result
39
+ except Exception as e2:
40
+ return {
41
+ "text": f"[Ошибка LLM] cloud: {e}; local: {e2}",
42
+ "tokens_in": 0, "tokens_out": 0,
43
+ "model": "none", "mode": "error",
44
+ }
@@ -0,0 +1,58 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Клиент DeepSeek API (через openai SDK).
4
+ """
5
+ import logging
6
+
7
+ from openai import OpenAI, APIConnectionError, RateLimitError, APIStatusError
8
+ from tenacity import (
9
+ retry,
10
+ stop_after_attempt,
11
+ wait_exponential,
12
+ retry_if_exception_type,
13
+ before_sleep_log,
14
+ )
15
+
16
+ from src.config import DEEPSEEK_API_KEY, DEEPSEEK_URL, DEEPSEEK_MODEL
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_client() -> OpenAI:
22
+ if not DEEPSEEK_API_KEY:
23
+ raise ValueError("DEEPSEEK_API_KEY не задан в .env")
24
+ return OpenAI(
25
+ api_key=DEEPSEEK_API_KEY,
26
+ base_url="https://api.deepseek.com/v1",
27
+ timeout=60.0, # dogfood P0: HTTP timeout (was default 600s)
28
+ max_retries=0, # retries handled by tenacity, not openai
29
+ )
30
+
31
+
32
+ @retry(
33
+ stop=stop_after_attempt(3),
34
+ wait=wait_exponential(multiplier=1, min=2, max=30),
35
+ retry=retry_if_exception_type((APIConnectionError, RateLimitError, APIStatusError, TimeoutError)),
36
+ before_sleep=before_sleep_log(logger, logging.WARNING),
37
+ reraise=True,
38
+ )
39
+ def chat(system: str, user: str,
40
+ temperature: float = 0.0,
41
+ max_tokens: int = 2000) -> dict:
42
+ """Возвращает {text, tokens_in, tokens_out, model}."""
43
+ client = get_client()
44
+ response = client.chat.completions.create(
45
+ model=DEEPSEEK_MODEL,
46
+ messages=[
47
+ {"role": "system", "content": system},
48
+ {"role": "user", "content": user},
49
+ ],
50
+ temperature=temperature,
51
+ max_tokens=max_tokens,
52
+ )
53
+ return {
54
+ "text": response.choices[0].message.content,
55
+ "tokens_in": response.usage.prompt_tokens,
56
+ "tokens_out": response.usage.completion_tokens,
57
+ "model": DEEPSEEK_MODEL,
58
+ }
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Клиент Ollama (локальный LLM).
4
+ """
5
+ import ollama
6
+ from src.config import OLLAMA_MODEL
7
+
8
+
9
+ def chat(system: str, user: str,
10
+ temperature: float = 0.0) -> dict:
11
+ """Возвращает {text, tokens_in, tokens_out, model}."""
12
+ response = ollama.chat(
13
+ model=OLLAMA_MODEL,
14
+ messages=[
15
+ {"role": "system", "content": system},
16
+ {"role": "user", "content": user},
17
+ ],
18
+ options={"temperature": temperature},
19
+ )
20
+ return {
21
+ "text": response["message"]["content"],
22
+ "tokens_in": 0,
23
+ "tokens_out": 0,
24
+ "model": OLLAMA_MODEL,
25
+ }