ChatService.php
- Pfad:
src/Infrastructure/AI/ChatService.php - Namespace: Infrastructure\AI
- Zeilen: 324 | Größe: 12,170 Bytes
- Geändert: 2025-12-28 02:29:18 | Gescannt: 2025-12-31 10:22:15
Code Hygiene Score: 83
- Dependencies: 100 (25%)
- LOC: 13 (20%)
- Methods: 100 (20%)
- Secrets: 100 (15%)
- Classes: 100 (10%)
- Magic Numbers: 100 (10%)
Keine Issues gefunden.
Dependencies 6
- constructor Infrastructure\AI\OllamaService
- constructor Infrastructure\AI\QdrantService
- constructor Infrastructure\AI\ClaudeService
- constructor Infrastructure\AI\ScoringService
- constructor Infrastructure\AI\SemanticEnrichmentService
- use RuntimeException
Klassen 1
-
ChatServiceclass Zeile 11
Funktionen 5
-
__construct()public Zeile 13 -
chat()public Zeile 30 -
buildContext()private Zeile 185 -
searchMultipleCollections()private Zeile 224 -
extractSources()private Zeile 269
Verwendet von 5
- ChatController.php use
- ChatController.php constructor
- ChatServiceProvider.php use
- SendChatMessageUseCase.php constructor
- SendChatMessageUseCase.php use
Versionen 22
-
v22
2025-12-28 02:29 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v21
2025-12-28 02:29 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v20
2025-12-28 02:28 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v19
2025-12-28 02:28 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v18
2025-12-28 02:28 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v17
2025-12-27 00:14 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v16
2025-12-27 00:14 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v15
2025-12-27 00:13 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v14
2025-12-27 00:13 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v13
2025-12-25 01:55 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v12
2025-12-25 01:55 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v11
2025-12-24 16:22 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v10
2025-12-24 16:21 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v9
2025-12-24 16:21 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v8
2025-12-23 07:59 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v7
2025-12-23 07:58 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v6
2025-12-20 19:15 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v5
2025-12-20 19:15 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v4
2025-12-20 19:14 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v3
2025-12-20 18:30 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v2
2025-12-20 18:30 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation -
v1
2025-12-20 18:30 | claude-code-hook | modified
Claude Code Pre-Hook Backup vor Edit-Operation
Code
<?php
declare(strict_types=1);
namespace Infrastructure\AI;
// @responsibility: RAG-Pipeline für KI-Chat (Embedding, Suche, Antwort-Generierung)
use RuntimeException;
final readonly class ChatService
{
public function __construct(
private OllamaService $ollama,
private QdrantService $qdrant,
private ClaudeService $claude,
private ScoringService $scoring,
private ?SemanticEnrichmentService $semantic = null
) {
}
/**
* Executes a complete RAG chat pipeline: Embedding → Search → Context → LLM → Response
* If no collections selected, skips RAG steps (direct LLM query).
*
* @param array<string> $collections Qdrant collections to search (empty = no RAG)
* @return array{question: string, answer: string, sources: array, model: string, usage?: array, chunks_used: int}
* @throws RuntimeException If embedding/search/LLM fails
*/
public function chat(
string $question,
string $model = 'claude-opus-4-5-20251101',
array $collections = [],
int $limit = 5,
?string $stylePrompt = null,
?string $customSystemPrompt = null,
float $temperature = 0.7,
int $maxTokens = 4096
): array {
$searchResults = [];
$context = '';
// Only perform RAG if collections are selected
if ($collections !== []) {
// Step 1: Generate embedding for the question
try {
$queryEmbedding = $this->ollama->getEmbedding($question);
} catch (RuntimeException $e) {
throw new RuntimeException(
'Embedding generation failed: ' . $e->getMessage(),
0,
$e
);
}
if ($queryEmbedding === []) {
throw new RuntimeException('Embedding generation returned empty vector');
}
// Step 2: Search across all selected collections
try {
$searchResults = $this->searchMultipleCollections($queryEmbedding, $collections, $limit);
} catch (RuntimeException $e) {
throw new RuntimeException(
'Vector search failed: ' . $e->getMessage(),
0,
$e
);
}
// Step 2b: Semantic enrichment (graceful degradation - works without)
$semanticSummary = '';
if ($searchResults !== [] && $this->semantic !== null) {
$searchResults = $this->semantic->enrichSearchResults($searchResults);
$semanticSummary = $this->semantic->buildSemanticSummary($searchResults);
}
// Step 3: Build context from search results (if any found)
if ($searchResults !== []) {
$context = $this->buildContext($searchResults, 3000, $semanticSummary);
}
}
// Step 4: Parse model string and generate answer
$isOllama = str_starts_with($model, 'ollama:');
$isClaude = str_starts_with($model, 'claude-');
$hasContext = $context !== '';
if ($isClaude) {
try {
// Build prompt: RAG with context or direct question
if ($hasContext) {
$userPrompt = $this->claude->buildRagPrompt($question, $context);
} else {
$userPrompt = $question;
}
// Build system prompt hierarchy: Default -> Custom -> Style
if ($customSystemPrompt !== null && $customSystemPrompt !== '') {
$systemPrompt = $customSystemPrompt;
} else {
$systemPrompt = $hasContext
? $this->claude->getDefaultSystemPrompt()
: 'Du bist ein hilfreicher Assistent. Antworte auf Deutsch, präzise und hilfreich.';
}
// Append style prompt from author profile if provided
if ($stylePrompt !== null && $stylePrompt !== '') {
$systemPrompt .= "\n\n" . $stylePrompt;
}
$llmResponse = $this->claude->ask($userPrompt, $systemPrompt, $model, $maxTokens, $temperature);
$answer = $llmResponse['text'];
$usage = $llmResponse['usage'];
} catch (RuntimeException $e) {
throw new RuntimeException(
'Claude API request failed: ' . $e->getMessage(),
0,
$e
);
}
} elseif ($isOllama) {
try {
// Extract actual model name (remove "ollama:" prefix)
$ollamaModel = substr($model, 7);
// Build instruction from custom prompt and style
$instructions = [];
if ($customSystemPrompt !== null && $customSystemPrompt !== '') {
$instructions[] = $customSystemPrompt;
}
if ($stylePrompt !== null && $stylePrompt !== '') {
$instructions[] = $stylePrompt;
}
$instructionBlock = $instructions !== [] ? implode("\n\n", $instructions) . "\n\n" : '';
// Build prompt: RAG with context or direct question
if ($hasContext) {
$userPrompt = sprintf(
"%sKontext aus den Dokumenten:\n\n%s\n\n---\n\nFrage: %s",
$instructionBlock,
$context,
$question
);
} else {
$userPrompt = $instructionBlock . $question;
}
$answer = $this->ollama->generate($userPrompt, $ollamaModel, $temperature);
$usage = null;
} catch (RuntimeException $e) {
throw new RuntimeException(
'Ollama generation failed: ' . $e->getMessage(),
0,
$e
);
}
} else {
throw new RuntimeException(
sprintf('Unknown model "%s". Use claude-* or ollama:* format.', $model)
);
}
// Step 5: Extract source information
$sources = $this->extractSources($searchResults);
// Step 6: Assemble response
$response = [
'question' => $question,
'answer' => $answer,
'sources' => $sources,
'model' => $model,
'chunks_used' => count($searchResults),
];
if ($usage !== null) {
$response['usage'] = $usage;
}
return $response;
}
/** Builds context string from search results (respects maxTokens limit). */
private function buildContext(array $searchResults, int $maxTokens = 3000, string $semanticSummary = ''): string
{
$contextParts = [];
$totalChars = 0;
$maxChars = $maxTokens * 4; // Approximate: 1 token ~ 4 characters
// Prepend semantic summary if available
if ($semanticSummary !== '') {
$contextParts[] = "[Semantischer Kontext]\n" . $semanticSummary;
$totalChars += strlen($semanticSummary);
}
foreach ($searchResults as $index => $result) {
$payload = $result['payload'];
// Support both payload schemas: documents + dokumentation_chunks
$content = (string) ($payload['content'] ?? $payload['content_preview'] ?? '');
$docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? 'Unbekannt');
// Check if adding this chunk would exceed the limit
if ($totalChars + strlen($content) > $maxChars) {
break;
}
// Build chunk header with optional entity info
$entities = $payload['entities'] ?? [];
$entityInfo = '';
if ($entities !== []) {
$entityNames = array_slice(array_column($entities, 'name'), 0, 3);
$entityInfo = ' | Enthält: ' . implode(', ', $entityNames);
}
$contextParts[] = sprintf('[Quelle %d: %s%s]%s%s', $index + 1, $docTitle, $entityInfo, "\n", $content);
$totalChars += strlen($content);
}
return implode("\n\n---\n\n", $contextParts);
}
/** Searches multiple collections, merges and returns top N results by weighted score. */
private function searchMultipleCollections(array $embedding, array $collections, int $limit): array
{
$allResults = [];
foreach ($collections as $collection) {
try {
$results = $this->qdrant->search($embedding, $collection, $limit);
// Add collection name to each result's payload for reference
foreach ($results as &$result) {
$result['payload']['_collection'] = $collection;
}
$allResults = array_merge($allResults, $results);
} catch (RuntimeException) {
// Skip unavailable collections, continue with others
continue;
}
}
// Apply weighted scoring (similarity + recency + authority)
foreach ($allResults as &$result) {
$processedAt = $result['payload']['processed_at'] ?? null;
$documentDate = $processedAt !== null
? new \DateTime($processedAt)
: new \DateTime();
$authorityScore = (float) ($result['payload']['authority_score'] ?? 0.5);
$result['weighted_score'] = $this->scoring->calculateScore(
$result['score'],
$documentDate,
$authorityScore
);
}
unset($result);
// Sort by weighted score descending
usort($allResults, static fn ($a, $b) => ($b['weighted_score'] ?? 0.0) <=> ($a['weighted_score'] ?? 0.0));
// Return top N results
return array_slice($allResults, 0, $limit);
}
/** Extracts unique source information (title, score, optional content) from search results. */
private function extractSources(array $searchResults): array
{
$sources = [];
$seen = [];
foreach ($searchResults as $result) {
$payload = $result['payload'];
// Support both payload schemas: documents + dokumentation_chunks
$docTitle = (string) ($payload['document_title'] ?? $payload['title'] ?? '');
// Skip empty titles or already seen titles
if ($docTitle === '' || isset($seen[$docTitle])) {
continue;
}
$source = [
'title' => $docTitle,
'score' => round($result['score'], 3),
];
// Optionally include content preview (support both field names)
$content = $payload['content'] ?? $payload['content_preview'] ?? null;
if (is_string($content) && $content !== '') {
$source['content'] = $content;
}
// Add collection info if available
if (isset($payload['_collection'])) {
$source['collection'] = $payload['_collection'];
}
// Add semantic data if available (graceful degradation)
$entities = $payload['entities'] ?? [];
if ($entities !== []) {
$source['entities'] = array_map(
static fn ($e) => ['name' => $e['name'], 'type' => $e['type']],
array_slice($entities, 0, 5)
);
}
$taxonomy = $payload['taxonomy'] ?? [];
if ($taxonomy !== []) {
$source['taxonomy'] = array_map(
static fn ($t) => $t['term_name'],
array_slice($taxonomy, 0, 3)
);
}
$sources[] = $source;
$seen[$docTitle] = true;
}
return $sources;
}
}