assessment.service.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026
  1. import {
  2. Injectable,
  3. Logger,
  4. NotFoundException,
  5. Inject,
  6. forwardRef,
  7. } from '@nestjs/common';
  8. import { InjectRepository } from '@nestjs/typeorm';
  9. import { Repository, DeepPartial } from 'typeorm';
  10. import { ConfigService } from '@nestjs/config';
  11. import { ChatOpenAI } from '@langchain/openai';
  12. import {
  13. HumanMessage,
  14. BaseMessage,
  15. AIMessage,
  16. SystemMessage,
  17. } from '@langchain/core/messages';
  18. import { Observable, from, map, mergeMap, concatMap } from 'rxjs';
  19. import {
  20. AssessmentSession,
  21. AssessmentStatus,
  22. } from './entities/assessment-session.entity';
  23. import { AssessmentQuestion } from './entities/assessment-question.entity';
  24. import { AssessmentAnswer } from './entities/assessment-answer.entity';
  25. import { AssessmentTemplate } from './entities/assessment-template.entity';
  26. import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
  27. import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
  28. import { ModelConfigService } from '../model-config/model-config.service';
  29. import { ModelType } from '../types';
  30. import { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
  31. import { RagService } from '../rag/rag.service';
  32. import { ChatService } from '../chat/chat.service';
  33. import { createEvaluationGraph } from './graph/builder';
  34. import { EvaluationState } from './graph/state';
  35. import { TemplateService } from './services/template.service';
  36. import { ContentFilterService } from './services/content-filter.service';
  37. import { I18nService } from '../i18n/i18n.service';
  38. import { TenantService } from '../tenant/tenant.service';
  39. @Injectable()
  40. export class AssessmentService {
  41. private readonly logger = new Logger(AssessmentService.name);
  42. private readonly graph = createEvaluationGraph();
  43. constructor(
  44. @InjectRepository(AssessmentSession)
  45. private sessionRepository: Repository<AssessmentSession>,
  46. @InjectRepository(AssessmentQuestion)
  47. private questionRepository: Repository<AssessmentQuestion>,
  48. @InjectRepository(AssessmentAnswer)
  49. private answerRepository: Repository<AssessmentAnswer>,
  50. @Inject(forwardRef(() => KnowledgeBaseService))
  51. private kbService: KnowledgeBaseService,
  52. @Inject(forwardRef(() => KnowledgeGroupService))
  53. private groupService: KnowledgeGroupService,
  54. @Inject(forwardRef(() => ModelConfigService))
  55. private modelConfigService: ModelConfigService,
  56. private configService: ConfigService,
  57. private templateService: TemplateService,
  58. private contentFilterService: ContentFilterService,
  59. private ragService: RagService,
  60. @Inject(forwardRef(() => ChatService))
  61. private chatService: ChatService,
  62. private i18nService: I18nService,
  63. private tenantService: TenantService,
  64. ) {}
  65. private async getModel(tenantId: string): Promise<ChatOpenAI> {
  66. const config = await this.modelConfigService.findDefaultByType(
  67. tenantId,
  68. ModelType.LLM,
  69. );
  70. return new ChatOpenAI({
  71. apiKey: config.apiKey || 'ollama',
  72. modelName: config.modelId,
  73. temperature: 0.7,
  74. configuration: {
  75. baseURL: config.baseUrl || 'http://localhost:11434/v1',
  76. },
  77. });
  78. }
  79. /**
  80. * Starts a new assessment session.
  81. */
  82. private async getSessionContent(session: {
  83. knowledgeBaseId?: string | null;
  84. knowledgeGroupId?: string | null;
  85. userId: string;
  86. tenantId: string;
  87. templateJson?: any;
  88. }): Promise<string> {
  89. const kbId = session.knowledgeBaseId || session.knowledgeGroupId;
  90. this.logger.log(`[getSessionContent] Starting for KB/Group ID: ${kbId}`);
  91. if (!kbId) {
  92. this.logger.warn(`[getSessionContent] No KB/Group ID provided`);
  93. return '';
  94. }
  95. const keywords = session.templateJson?.keywords || [];
  96. // If keywords are provided, use RagService (Hybrid Search) to find relevant content
  97. if (keywords.length > 0) {
  98. this.logger.log(
  99. `[getSessionContent] Keywords detected, performing hybrid search via RagService: ${keywords.join(', ')}`,
  100. );
  101. try {
  102. // 1. Determine file IDs to include in search
  103. let fileIds: string[] = [];
  104. if (session.knowledgeBaseId) {
  105. fileIds = [session.knowledgeBaseId];
  106. } else if (session.knowledgeGroupId) {
  107. fileIds = await this.groupService.getFileIdsByGroups(
  108. [session.knowledgeGroupId],
  109. session.userId,
  110. session.tenantId,
  111. );
  112. }
  113. if (fileIds.length > 0) {
  114. const query = keywords.join(' ');
  115. this.logger.log(
  116. `[getSessionContent] Performing high-fidelity grounded search (streamChat-style). Keywords: "${query}"`,
  117. );
  118. // 1. Get default embedding model (strict logic from streamChat)
  119. const embeddingModel =
  120. await this.modelConfigService.findDefaultByType(
  121. session.tenantId || 'default',
  122. ModelType.EMBEDDING,
  123. );
  124. // 2. Perform advanced RAG search
  125. const ragResults = await this.ragService.searchKnowledge(
  126. query,
  127. session.userId,
  128. 20, // Increased topK to 20 for broader question coverage
  129. 0.1, // Lenient similarityThreshold (Chat/Rag defaults are 0.3)
  130. embeddingModel?.id,
  131. true, // enableFullTextSearch
  132. true, // enableRerank
  133. undefined, // selectedRerankId
  134. undefined, // selectedGroups
  135. fileIds,
  136. 0.3, // Lenient rerankSimilarityThreshold (Chat/Rag defaults are 0.5)
  137. session.tenantId,
  138. );
  139. // 3. Format context using localized labels (equivalent to buildContext)
  140. const language = session.templateJson?.language || 'zh';
  141. const searchContent = ragResults
  142. .map((result, index) => {
  143. // this.logger.debug(`[getSessionContent] Found chunk [${index + 1}]: score=${result.score.toFixed(4)}, file=${result.fileName}, contentPreview=${result.content}...`);
  144. return `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`;
  145. })
  146. .join('\n');
  147. if (searchContent && searchContent.trim().length > 0) {
  148. this.logger.log(
  149. `[getSessionContent] SUCCESS: Found ${ragResults.length} relevant chunks. Total length: ${searchContent.length}`,
  150. );
  151. // this.logger.log(`[getSessionContent] --- AI Context Start ---\n${searchContent}\n[getSessionContent] --- AI Context End ---`);
  152. return searchContent;
  153. } else {
  154. this.logger.warn(
  155. `[getSessionContent] High-fidelity search returned no results for query: "${query}".`,
  156. );
  157. }
  158. } else {
  159. this.logger.warn(
  160. `[getSessionContent] No files found for search scope (KB: ${session.knowledgeBaseId}, Group: ${session.knowledgeGroupId})`,
  161. );
  162. }
  163. } catch (err) {
  164. this.logger.error(
  165. `[getSessionContent] Grounded search failed unexpectedly: ${err.message}`,
  166. err.stack,
  167. );
  168. }
  169. this.logger.warn(
  170. `[getSessionContent] Grounded search failed or returned nothing. One common reason is that the keywords are not present in the indexed documents.`,
  171. );
  172. }
  173. // Fallback or No Keywords: Original behavior (full content retrieval)
  174. let content = '';
  175. if (session.knowledgeBaseId) {
  176. this.logger.debug(
  177. `[getSessionContent] Fetching content for KnowledgeBase: ${kbId}`,
  178. );
  179. const kb = await (this.kbService as any).kbRepository.findOne({
  180. where: { id: kbId, tenantId: session.tenantId },
  181. });
  182. if (kb) {
  183. content = kb.content || '';
  184. this.logger.debug(
  185. `[getSessionContent] Found KB content, length: ${content.length}`,
  186. );
  187. } else {
  188. this.logger.warn(
  189. `[getSessionContent] KnowledgeBase not found: ${kbId}`,
  190. );
  191. }
  192. } else {
  193. try {
  194. this.logger.debug(
  195. `[getSessionContent] Fetching content for KnowledgeGroup: ${kbId}`,
  196. );
  197. const groupFiles = await this.groupService.getGroupFiles(
  198. kbId,
  199. session.userId,
  200. session.tenantId,
  201. );
  202. this.logger.debug(
  203. `[getSessionContent] Found ${groupFiles.length} files in group`,
  204. );
  205. content = groupFiles
  206. .filter((f) => f.content)
  207. .map((f) => {
  208. this.logger.debug(
  209. `[getSessionContent] Including file: ${f.title || f.originalName}, content length: ${f.content?.length || 0}`,
  210. );
  211. return `--- Document: ${f.title || f.originalName} ---\n${f.content}`;
  212. })
  213. .join('\n\n');
  214. this.logger.debug(
  215. `[getSessionContent] Total group content length: ${content.length}`,
  216. );
  217. } catch (err) {
  218. this.logger.error(
  219. `[getSessionContent] Failed to get group files: ${err.message}`,
  220. );
  221. }
  222. }
  223. // Apply keyword filter (regex based) as an extra layer if still using full content
  224. if (content && keywords.length > 0) {
  225. this.logger.debug(
  226. `[getSessionContent] Applying fallback keyword filters: ${keywords.join(', ')}`,
  227. );
  228. const prevLen = content.length;
  229. content = this.contentFilterService.filterContent(content, keywords);
  230. this.logger.debug(
  231. `[getSessionContent] After filtering, content length: ${content.length} (was ${prevLen})`,
  232. );
  233. }
  234. this.logger.log(
  235. `[getSessionContent] Final content for AI generation (Length: ${content.length})`,
  236. );
  237. this.logger.debug(
  238. `[getSessionContent] Content Preview: ${content.substring(0, 500)}...`,
  239. );
  240. return content;
  241. }
  242. /**
  243. * Starts a new assessment session.
  244. * kbId can be a KnowledgeBase ID or a KnowledgeGroup ID.
  245. */
  246. async startSession(
  247. userId: string,
  248. kbId: string | undefined,
  249. tenantId: string,
  250. language: string = 'en',
  251. templateId?: string,
  252. ): Promise<AssessmentSession> {
  253. this.logger.log(
  254. `[startSession] Starting session for user ${userId}, templateId: ${templateId}, kbId: ${kbId}`,
  255. );
  256. let template: AssessmentTemplate | null = null;
  257. if (templateId) {
  258. template = await this.templateService.findOne(
  259. templateId,
  260. userId,
  261. tenantId,
  262. );
  263. this.logger.debug(
  264. `[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`,
  265. );
  266. }
  267. // Use kbId if provided, otherwise fall back to template's group ID
  268. const activeKbId = kbId || template?.knowledgeGroupId;
  269. this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
  270. if (!activeKbId) {
  271. this.logger.error(`[startSession] No knowledge source resolved`);
  272. throw new Error('Knowledge source (ID or Template) must be provided.');
  273. }
  274. // Try to determine if it's a KB or Group and check permissions
  275. let isKb = false;
  276. try {
  277. await this.kbService.findOne(activeKbId, userId, tenantId);
  278. isKb = true;
  279. } catch (kbError) {
  280. if (kbError instanceof NotFoundException) {
  281. // Try finding it as a Group
  282. try {
  283. await this.groupService.findOne(activeKbId, userId, tenantId);
  284. } catch (groupError) {
  285. this.logger.error(
  286. `[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
  287. );
  288. throw new NotFoundException(
  289. this.i18nService.getMessage('knowledgeSourceNotFound') ||
  290. 'Knowledge source not found',
  291. );
  292. }
  293. } else {
  294. throw kbError; // e.g. ForbiddenException
  295. }
  296. }
  297. this.logger.debug(`[startSession] isKb: ${isKb}`);
  298. const templateData = template
  299. ? {
  300. name: template.name,
  301. keywords: template.keywords,
  302. questionCount: template.questionCount,
  303. difficultyDistribution: template.difficultyDistribution,
  304. style: template.style,
  305. }
  306. : undefined;
  307. const sessionData: any = {
  308. userId,
  309. tenantId,
  310. knowledgeBaseId: isKb ? activeKbId : undefined,
  311. knowledgeGroupId: isKb ? undefined : activeKbId,
  312. templateId,
  313. templateJson: templateData,
  314. status: AssessmentStatus.IN_PROGRESS,
  315. language,
  316. };
  317. const content = await this.getSessionContent(sessionData);
  318. if (!content || content.trim().length < 10) {
  319. this.logger.error(
  320. `[startSession] Insufficient content length: ${content?.length || 0}`,
  321. );
  322. throw new Error(
  323. 'Selected knowledge source has no sufficient content for evaluation.',
  324. );
  325. }
  326. const session = this.sessionRepository.create(
  327. sessionData as DeepPartial<AssessmentSession>,
  328. );
  329. const savedSession = (await this.sessionRepository.save(
  330. session as any,
  331. )) as AssessmentSession;
  332. // Thread ID for LangGraph is the session ID
  333. savedSession.threadId = savedSession.id;
  334. await this.sessionRepository.save(savedSession);
  335. this.logger.log(
  336. `[startSession] Session ${savedSession.id} created and saved`,
  337. );
  338. return savedSession;
  339. }
  340. /**
  341. * Specialized streaming start for initial generation.
  342. */
  343. startSessionStream(sessionId: string, userId: string): Observable<any> {
  344. return new Observable((observer) => {
  345. (async () => {
  346. try {
  347. const session = await this.sessionRepository.findOne({
  348. where: { id: sessionId, userId },
  349. });
  350. if (!session) {
  351. observer.error(new NotFoundException('Session not found'));
  352. return;
  353. }
  354. const model = await this.getModel(session.tenantId);
  355. const content = await this.getSessionContent(session);
  356. // Check if we already have state
  357. const existingState = await this.graph.getState({
  358. configurable: { thread_id: sessionId },
  359. });
  360. if (
  361. existingState &&
  362. existingState.values &&
  363. existingState.values.questions?.length > 0
  364. ) {
  365. this.logger.log(
  366. `Session ${sessionId} already has state, skipping generation.`,
  367. );
  368. const mappedData = { ...existingState.values };
  369. mappedData.messages = this.mapMessages(mappedData.messages || []);
  370. mappedData.feedbackHistory = this.mapMessages(
  371. mappedData.feedbackHistory || [],
  372. );
  373. observer.next({ type: 'final', data: mappedData });
  374. observer.complete();
  375. return;
  376. }
  377. const initialState: Partial<EvaluationState> = {
  378. assessmentSessionId: sessionId,
  379. knowledgeBaseId:
  380. session.knowledgeBaseId || session.knowledgeGroupId || '',
  381. messages: [],
  382. questionCount: session.templateJson?.questionCount,
  383. difficultyDistribution:
  384. session.templateJson?.difficultyDistribution,
  385. style: session.templateJson?.style,
  386. keywords: session.templateJson?.keywords,
  387. };
  388. const isZh = (session.language || 'en') === 'zh';
  389. const isJa = session.language === 'ja';
  390. const initialMsg = isZh
  391. ? '现在生成评估问题。请务必使用中文。'
  392. : isJa
  393. ? '今すぐアセスメント問題を生成してください。必ず日本語で回答してください。'
  394. : 'Generate the assessment questions now. Please strictly respond in English.';
  395. this.logger.log(
  396. `[startSessionStream] Starting stream for session ${sessionId}`,
  397. );
  398. const stream = await this.graph.stream(
  399. {
  400. ...initialState,
  401. language: session.language || 'en', // Ensure language is passed in initial state
  402. messages: [new HumanMessage(initialMsg)],
  403. },
  404. {
  405. configurable: {
  406. thread_id: sessionId,
  407. model,
  408. knowledgeBaseContent: content,
  409. language: session.language || 'en',
  410. questionCount: session.templateJson?.questionCount,
  411. difficultyDistribution:
  412. session.templateJson?.difficultyDistribution,
  413. style: session.templateJson?.style,
  414. keywords: session.templateJson?.keywords,
  415. },
  416. streamMode: ['values', 'updates'],
  417. },
  418. );
  419. this.logger.debug(`[startSessionStream] Graph stream started`);
  420. for await (const [mode, data] of stream) {
  421. if (mode === 'updates') {
  422. const node = Object.keys(data)[0];
  423. const updateData = { ...data[node] };
  424. if (updateData.messages) {
  425. updateData.messages = this.mapMessages(updateData.messages);
  426. }
  427. if (updateData.feedbackHistory) {
  428. updateData.feedbackHistory = this.mapMessages(
  429. updateData.feedbackHistory,
  430. );
  431. }
  432. observer.next({ type: 'node', node, data: updateData });
  433. }
  434. }
  435. // After stream, get the latest authoritative state from checkpointer
  436. const fullState = await this.graph.getState({
  437. configurable: { thread_id: sessionId },
  438. });
  439. const finalData = fullState.values as EvaluationState;
  440. if (finalData && finalData.messages) {
  441. console.log(
  442. `[AssessmentService] startSessionStream Final Authoritative State messages:`,
  443. finalData.messages.length,
  444. );
  445. session.messages = finalData.messages;
  446. session.feedbackHistory = finalData.feedbackHistory || [];
  447. session.questions_json = finalData.questions;
  448. session.currentQuestionIndex = finalData.currentQuestionIndex;
  449. session.followUpCount = finalData.followUpCount || 0;
  450. if (finalData.report) {
  451. session.status = AssessmentStatus.COMPLETED;
  452. session.finalReport = finalData.report;
  453. const scores = finalData.scores;
  454. const questions = finalData.questions || [];
  455. if (questions.length > 0 && Object.keys(scores).length > 0) {
  456. let totalWeightedScore = 0;
  457. let totalWeight = 0;
  458. questions.forEach((q: any, idx: number) => {
  459. const score = scores[q.id || idx.toString()];
  460. if (score !== undefined) {
  461. const weight =
  462. q.difficulty === 'Specialist'
  463. ? 2.0
  464. : q.difficulty === 'Advanced'
  465. ? 1.5
  466. : 1.0;
  467. totalWeightedScore += score * weight;
  468. totalWeight += weight;
  469. }
  470. });
  471. session.finalScore =
  472. totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
  473. }
  474. }
  475. await this.sessionRepository.save(session);
  476. const mappedData: any = { ...finalData };
  477. mappedData.messages = this.mapMessages(finalData.messages);
  478. mappedData.feedbackHistory = this.mapMessages(
  479. finalData.feedbackHistory || [],
  480. );
  481. mappedData.status = session.status;
  482. mappedData.report = session.finalReport;
  483. mappedData.finalScore = session.finalScore;
  484. observer.next({ type: 'final', data: mappedData });
  485. }
  486. observer.complete();
  487. } catch (err) {
  488. observer.error(err);
  489. }
  490. })();
  491. });
  492. }
  493. /**
  494. * Submits a user's answer and continues the assessment.
  495. */
  496. async submitAnswer(
  497. sessionId: string,
  498. userId: string,
  499. answer: string,
  500. language: string = 'en',
  501. ): Promise<any> {
  502. const session = await this.sessionRepository.findOne({
  503. where: { id: sessionId, userId },
  504. relations: ['template'],
  505. });
  506. if (!session) throw new NotFoundException('Session not found');
  507. const model = await this.getModel(session.tenantId);
  508. await this.ensureGraphState(sessionId, session);
  509. const content = await this.getSessionContent(session);
  510. // Update state with human message first to ensure it's in history before resumption
  511. await this.graph.updateState(
  512. { configurable: { thread_id: sessionId } },
  513. { messages: [new HumanMessage(answer)] },
  514. );
  515. this.logger.debug(`[submitAnswer] Resuming graph for session ${sessionId}`);
  516. let finalResult: any = null;
  517. // Resume from the last interrupt (typically after interviewer)
  518. const stream = await this.graph.stream(null, {
  519. configurable: {
  520. thread_id: sessionId,
  521. model,
  522. knowledgeBaseContent: content,
  523. language: session.language || language,
  524. questionCount: session.templateJson?.questionCount,
  525. difficultyDistribution: session.templateJson?.difficultyDistribution,
  526. style: session.templateJson?.style,
  527. keywords: session.templateJson?.keywords,
  528. },
  529. streamMode: ['values', 'updates'],
  530. });
  531. for await (const [mode, data] of stream) {
  532. if (mode === 'values') {
  533. // This might be the interrupt info if interrupted
  534. finalResult = data;
  535. } else if (mode === 'updates') {
  536. const nodeName = Object.keys(data)[0];
  537. this.logger.debug(`[submitAnswer] Node completed: ${nodeName}`);
  538. }
  539. }
  540. // Always get the latest authoritative state from checkpointer after the stream
  541. const fullState = await this.graph.getState({
  542. configurable: { thread_id: sessionId },
  543. });
  544. finalResult = fullState.values as EvaluationState;
  545. this.logger.log(
  546. `[submitAnswer] Stream finished. State Index: ${finalResult.currentQuestionIndex}, Questions: ${finalResult.questions?.length}, HasReport: ${!!finalResult.report}`,
  547. );
  548. if (finalResult && (finalResult.messages || finalResult.questions)) {
  549. session.messages = finalResult.messages;
  550. session.questions_json = finalResult.questions;
  551. session.currentQuestionIndex = finalResult.currentQuestionIndex;
  552. session.followUpCount = finalResult.followUpCount || 0;
  553. if (finalResult.report) {
  554. session.status = AssessmentStatus.COMPLETED;
  555. session.finalReport = finalResult.report;
  556. const scores = finalResult.scores as Record<string, number>;
  557. const questions = finalResult.questions || [];
  558. if (questions.length > 0 && Object.keys(scores).length > 0) {
  559. let totalWeightedScore = 0;
  560. let totalWeight = 0;
  561. questions.forEach((q: any, idx: number) => {
  562. const score = scores[q.id || idx.toString()];
  563. if (score !== undefined) {
  564. const weight =
  565. q.difficulty === 'Specialist'
  566. ? 2.0
  567. : q.difficulty === 'Advanced'
  568. ? 1.5
  569. : 1.0;
  570. totalWeightedScore += score * weight;
  571. totalWeight += weight;
  572. }
  573. });
  574. session.finalScore =
  575. totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
  576. }
  577. }
  578. session.feedbackHistory = finalResult.feedbackHistory || [];
  579. await this.sessionRepository.save(session);
  580. // Map result for return
  581. finalResult.messages = this.mapMessages(finalResult.messages);
  582. finalResult.feedbackHistory = this.mapMessages(
  583. finalResult.feedbackHistory || [],
  584. );
  585. finalResult.report = session.finalReport;
  586. finalResult.finalScore = session.finalScore;
  587. this.logger.log(
  588. `[submitAnswer] session saved. DB Status: ${session.status}, Index: ${session.currentQuestionIndex}`,
  589. );
  590. this.logger.log(
  591. `[submitAnswer] finalResult check: hasQuestions=${!!finalResult.questions}, questionsLen=${finalResult.questions?.length}, hasReport=${!!finalResult.report}`,
  592. );
  593. this.logger.debug(
  594. `[submitAnswer] finalResult keys: ${Object.keys(finalResult).join(', ')}`,
  595. );
  596. this.logger.log(
  597. `[submitAnswer] session updated: status=${session.status}, index=${session.currentQuestionIndex}`,
  598. );
  599. } else {
  600. this.logger.warn(
  601. `[submitAnswer] finalResult has no usable data! Keys: ${Object.keys(finalResult || {}).join(', ')}`,
  602. );
  603. }
  604. return finalResult;
  605. }
  606. /**
  607. * Streaming version of submitAnswer.
  608. */
  609. submitAnswerStream(
  610. sessionId: string,
  611. userId: string,
  612. answer: string,
  613. language: string = 'en',
  614. ): Observable<any> {
  615. return new Observable((observer) => {
  616. (async () => {
  617. try {
  618. const session = await this.sessionRepository.findOne({
  619. where: { id: sessionId, userId },
  620. });
  621. if (!session) {
  622. observer.error(new NotFoundException('Session not found'));
  623. return;
  624. }
  625. const model = await this.getModel(session.tenantId);
  626. const content = await this.getSessionContent(session);
  627. await this.ensureGraphState(sessionId, session);
  628. const graphState = await this.graph.getState({
  629. configurable: { thread_id: sessionId },
  630. });
  631. const hasState =
  632. graphState &&
  633. graphState.values &&
  634. Object.keys(graphState.values).length > 0;
  635. console.log(
  636. `[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
  637. );
  638. // Update state with human message first to ensure it's in history
  639. await this.graph.updateState(
  640. { configurable: { thread_id: sessionId } },
  641. { messages: [new HumanMessage(answer)] },
  642. );
  643. // Resume from the last interrupt
  644. const stream = await this.graph.stream(null, {
  645. configurable: {
  646. thread_id: sessionId,
  647. model,
  648. knowledgeBaseContent: content,
  649. language: session.language || language,
  650. },
  651. streamMode: ['values', 'updates'],
  652. });
  653. for await (const [mode, data] of stream) {
  654. if (mode === 'updates') {
  655. const node = Object.keys(data)[0];
  656. const updateData = { ...data[node] };
  657. if (updateData.messages) {
  658. updateData.messages = this.mapMessages(updateData.messages);
  659. }
  660. if (updateData.feedbackHistory) {
  661. updateData.feedbackHistory = this.mapMessages(
  662. updateData.feedbackHistory,
  663. );
  664. }
  665. observer.next({ type: 'node', node, data: updateData });
  666. }
  667. }
  668. // After stream, get authoritative state
  669. const fullState = await this.graph.getState({
  670. configurable: { thread_id: sessionId },
  671. });
  672. const finalData = fullState.values as EvaluationState;
  673. if (finalData && finalData.messages) {
  674. console.log(
  675. `[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
  676. finalData.messages.length,
  677. );
  678. session.messages = finalData.messages;
  679. session.feedbackHistory = finalData.feedbackHistory || [];
  680. session.questions_json = finalData.questions;
  681. session.currentQuestionIndex = finalData.currentQuestionIndex;
  682. session.followUpCount = finalData.followUpCount || 0;
  683. if (finalData.report) {
  684. session.status = AssessmentStatus.COMPLETED;
  685. session.finalReport = finalData.report;
  686. const scores = finalData.scores;
  687. const questions = finalData.questions || [];
  688. if (questions.length > 0 && Object.keys(scores).length > 0) {
  689. let totalWeightedScore = 0;
  690. let totalWeight = 0;
  691. questions.forEach((q: any, idx: number) => {
  692. const score = scores[q.id || idx.toString()];
  693. if (score !== undefined) {
  694. // Standard=1.0, Advanced=1.5, Specialist=2.0
  695. const weight =
  696. q.difficulty === 'Specialist'
  697. ? 2.0
  698. : q.difficulty === 'Advanced'
  699. ? 1.5
  700. : 1.0;
  701. totalWeightedScore += score * weight;
  702. totalWeight += weight;
  703. this.logger.debug(
  704. `[WeightedScoring] Q${idx}: Score=${score}, Difficulty=${q.difficulty}, Weight=${weight}`,
  705. );
  706. }
  707. });
  708. session.finalScore =
  709. totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
  710. this.logger.log(
  711. `[WeightedScoring] Session ${sessionId} Final Score: ${session.finalScore} (Weighted Avg)`,
  712. );
  713. }
  714. }
  715. await this.sessionRepository.save(session);
  716. const mappedData: any = { ...finalData };
  717. mappedData.messages = this.mapMessages(finalData.messages);
  718. mappedData.feedbackHistory = this.mapMessages(
  719. finalData.feedbackHistory || [],
  720. );
  721. mappedData.status = session.status;
  722. mappedData.report = session.finalReport;
  723. observer.next({ type: 'final', data: mappedData });
  724. }
  725. observer.complete();
  726. } catch (err) {
  727. observer.error(err);
  728. }
  729. })();
  730. });
  731. }
  732. /**
  733. * Retrieves the current state of a session.
  734. */
  735. async getSessionState(sessionId: string, userId: string): Promise<any> {
  736. this.logger.log(
  737. `Retrieving state for session ${sessionId} for user ${userId}`,
  738. );
  739. const session = await this.sessionRepository.findOne({
  740. where: { id: sessionId, userId },
  741. relations: ['template'],
  742. });
  743. if (!session) throw new NotFoundException('Session not found');
  744. // Ensure graph has state (lazy init or recovery)
  745. await this.ensureGraphState(sessionId, session);
  746. const state = await this.graph.getState({
  747. configurable: { thread_id: sessionId },
  748. });
  749. const values = { ...state.values };
  750. if (values.messages) {
  751. values.messages = this.mapMessages(values.messages);
  752. }
  753. if (values.feedbackHistory) {
  754. values.feedbackHistory = this.mapMessages(values.feedbackHistory);
  755. }
  756. return values;
  757. }
  758. /**
  759. * Retrieves assessment session history for a user.
  760. */
  761. async getHistory(
  762. userId: string,
  763. tenantId: string,
  764. ): Promise<AssessmentSession[]> {
  765. const history = await this.sessionRepository.find({
  766. where: { userId, tenantId },
  767. order: { createdAt: 'DESC' },
  768. relations: ['knowledgeBase', 'knowledgeGroup'],
  769. });
  770. // Map questions_json to questions for frontend compatibility
  771. const mappedHistory = history.map((session) => ({
  772. ...session,
  773. questions: session.questions_json || [],
  774. })) as any;
  775. this.logger.log(`Found ${history.length} historical sessions`);
  776. return mappedHistory;
  777. }
  778. /**
  779. * Deletes an assessment session.
  780. */
  781. async deleteSession(sessionId: string, user: any): Promise<void> {
  782. this.logger.log(
  783. `Deleting session ${sessionId} for user ${user.id} (role: ${user.role})`,
  784. );
  785. const userId = user.id;
  786. const isAdmin = user.role === 'super_admin' || user.role === 'admin';
  787. const deleteCondition: any = { id: sessionId };
  788. if (!isAdmin) {
  789. deleteCondition.userId = userId;
  790. }
  791. const result = await this.sessionRepository.delete(deleteCondition);
  792. if (result.affected === 0) {
  793. throw new NotFoundException(
  794. 'Session not found or you do not have permission to delete it',
  795. );
  796. }
  797. }
  798. /**
  799. * Ensures the graph checkpointer has the state for the given session.
  800. * Useful for lazy initialization and recovery after server restarts.
  801. */
  802. private async ensureGraphState(
  803. sessionId: string,
  804. session: AssessmentSession,
  805. ): Promise<void> {
  806. const state = await this.graph.getState({
  807. configurable: { thread_id: sessionId },
  808. });
  809. if (
  810. !state.values ||
  811. Object.keys(state.values).length === 0 ||
  812. !state.values.messages ||
  813. state.values.messages.length === 0
  814. ) {
  815. const hasHistory = session.messages && session.messages.length > 0;
  816. if (hasHistory) {
  817. this.logger.log(
  818. `[ensureGraphState] Recovering state from DB for session ${sessionId}`,
  819. );
  820. const historicalMessages = this.hydrateMessages(session.messages);
  821. await this.graph.updateState(
  822. { configurable: { thread_id: sessionId } },
  823. {
  824. assessmentSessionId: sessionId,
  825. knowledgeBaseId:
  826. session.knowledgeBaseId || session.knowledgeGroupId || '',
  827. messages: historicalMessages,
  828. feedbackHistory: this.hydrateMessages(
  829. session.feedbackHistory || [],
  830. ),
  831. questions: session.questions_json || [],
  832. currentQuestionIndex: session.currentQuestionIndex || 0,
  833. followUpCount: session.followUpCount || 0,
  834. questionCount: session.templateJson?.questionCount || 5,
  835. difficultyDistribution:
  836. session.templateJson?.difficultyDistribution,
  837. style: session.templateJson?.style,
  838. keywords: session.templateJson?.keywords,
  839. },
  840. 'grader', // Recovering a session with messages should prep for grading the next input
  841. );
  842. } else {
  843. this.logger.log(`Initializing new state for session ${sessionId}`);
  844. const content = await this.getSessionContent(session);
  845. const model = await this.getModel(session.tenantId);
  846. const initialState: Partial<EvaluationState> = {
  847. assessmentSessionId: sessionId,
  848. knowledgeBaseId:
  849. session.knowledgeBaseId || session.knowledgeGroupId || '',
  850. messages: [],
  851. questionCount: session.templateJson?.questionCount,
  852. difficultyDistribution: session.templateJson?.difficultyDistribution,
  853. style: session.templateJson?.style,
  854. keywords: session.templateJson?.keywords,
  855. language: session.language || 'en',
  856. };
  857. this.logger.log(
  858. `[ensureGraphState] Initializing with questionCount=${initialState.questionCount}, keywords=${initialState.keywords?.join(',')}, style=${initialState.style}`,
  859. );
  860. const resultStream = await this.graph.stream(initialState, {
  861. configurable: {
  862. thread_id: sessionId,
  863. model,
  864. knowledgeBaseContent: content,
  865. language: session.language || 'en',
  866. keywords: session.templateJson?.keywords,
  867. questionCount: session.templateJson?.questionCount,
  868. difficultyDistribution:
  869. session.templateJson?.difficultyDistribution,
  870. style: session.templateJson?.style,
  871. },
  872. streamMode: ['values', 'updates'],
  873. });
  874. let finalInvokeResult: any = null;
  875. const nodes: string[] = [];
  876. for await (const [mode, data] of resultStream) {
  877. if (mode === 'values') finalInvokeResult = data;
  878. else if (mode === 'updates') nodes.push(...Object.keys(data));
  879. }
  880. if (finalInvokeResult.messages) {
  881. session.messages = finalInvokeResult.messages;
  882. session.feedbackHistory = finalInvokeResult.feedbackHistory || [];
  883. session.questions_json = finalInvokeResult.questions;
  884. session.currentQuestionIndex = finalInvokeResult.currentQuestionIndex;
  885. session.followUpCount = finalInvokeResult.followUpCount || 0;
  886. await this.sessionRepository.save(session);
  887. }
  888. }
  889. }
  890. }
  891. /**
  892. * Re-hydrates plain objects from DB into LangChain message instances.
  893. */
  894. private hydrateMessages(messages: any[]): BaseMessage[] {
  895. if (!messages) return [];
  896. return messages.map((m) => {
  897. if (m instanceof BaseMessage) return m;
  898. const content = m.content || m.text || (typeof m === 'string' ? m : '');
  899. const type = m.role || m.type || m._getType?.() || 'ai';
  900. if (type === 'human' || type === 'user') {
  901. return new HumanMessage(content);
  902. } else if (type === 'ai' || type === 'assistant') {
  903. return new AIMessage(content);
  904. } else if (type === 'system') {
  905. return new SystemMessage(content);
  906. }
  907. return new AIMessage(content);
  908. });
  909. }
  910. /**
  911. * Maps LangChain messages to a simple format for the frontend and storage.
  912. */
  913. private mapMessages(messages: BaseMessage[]): any[] {
  914. if (!messages) return [];
  915. return messages.map((msg) => {
  916. const type = msg._getType();
  917. let role: 'user' | 'assistant' | 'system' = 'system';
  918. if (type === 'human') role = 'user';
  919. else if (type === 'ai') role = 'assistant';
  920. else if (type === 'system') role = 'system';
  921. return {
  922. role,
  923. content: msg.content,
  924. type, // Also store the LangChain type for easier hydration
  925. timestamp: (msg as any).timestamp || Date.now(),
  926. };
  927. });
  928. }
  929. }