fix(d3/ai): implement pill-node geometry, text truncation, and resolve SignalR scroll loop [issue #21]
This commit is contained in:
@@ -3,4 +3,4 @@ using MediatR;
|
|||||||
|
|
||||||
namespace NexusReader.Application.Commands.Sync;
|
namespace NexusReader.Application.Commands.Sync;
|
||||||
|
|
||||||
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest<Result>;
|
||||||
|
|||||||
@@ -38,9 +38,18 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler<UpdateReading
|
|||||||
await context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// Broadcast to other devices
|
// Broadcast to other devices
|
||||||
await _hubContext.Clients
|
var group = _hubContext.Clients.Group($"User_{request.UserId}");
|
||||||
.Group($"User_{request.UserId}")
|
|
||||||
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
if (!string.IsNullOrEmpty(request.ExcludedConnectionId))
|
||||||
|
{
|
||||||
|
await _hubContext.Clients
|
||||||
|
.GroupExcept($"User_{request.UserId}", request.ExcludedConnectionId)
|
||||||
|
.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await group.SendAsync("ProgressUpdated", request.PageId, now, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class SyncHub : Hub
|
|||||||
var userId = Context.UserIdentifier;
|
var userId = Context.UserIdentifier;
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId));
|
await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId, Context.ConnectionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public static class PromptRegistry
|
|||||||
{
|
{
|
||||||
public const string KnowledgeExtractionSystemPrompt =
|
public const string KnowledgeExtractionSystemPrompt =
|
||||||
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
"You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " +
|
||||||
|
"CRITICAL: Restrict 'concept.label' to a maximum of 3 words (e.g., 'Dependency Injection' instead of full sentences). " +
|
||||||
|
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points from the text. " +
|
||||||
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
|
||||||
"Schema: { " +
|
"Schema: { " +
|
||||||
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
"\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " +
|
||||||
@@ -13,11 +15,14 @@ public static class PromptRegistry
|
|||||||
|
|
||||||
public const string GraphExtractionPrompt =
|
public const string GraphExtractionPrompt =
|
||||||
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
|
"You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " +
|
||||||
|
"CRITICAL: Restrict 'label' to a maximum of 3 words. " +
|
||||||
|
"CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " +
|
||||||
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " +
|
"CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " +
|
||||||
"Include a 'current' node representing the block content itself if applicable. " +
|
"Include a 'current' node representing the block content itself if applicable. " +
|
||||||
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
|
"CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " +
|
||||||
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
|
"Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }";
|
||||||
|
|
||||||
|
|
||||||
public const string SummaryAndQuizPrompt =
|
public const string SummaryAndQuizPrompt =
|
||||||
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
"You are an expert educator. Provide a concise summary of the text and generate a challenging quiz (3-5 questions). " +
|
||||||
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
"Return ONLY minified JSON. Schema: { \"summary\": \"string\", \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }";
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
{
|
{
|
||||||
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
QuizState.RequestQuiz(ContextBlockId);
|
await QuizState.RequestQuiz(ContextBlockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (OnActionTriggered.HasDelegate)
|
if (OnActionTriggered.HasDelegate)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
|
|
||||||
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
public async Task<KnowledgePacket?> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||||
{
|
{
|
||||||
_quizService.SetHydrating(true);
|
await _quizService.SetHydrating(true);
|
||||||
LogRequestingSummary(tenantId);
|
LogRequestingSummary(tenantId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
|
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
||||||
await _platformService.VibrateSuccessAsync();
|
await _platformService.VibrateSuccessAsync();
|
||||||
return packet;
|
return packet;
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_quizService.SetHydrating(false);
|
await _quizService.SetHydrating(false);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
|||||||
public async Task ClearAsync()
|
public async Task ClearAsync()
|
||||||
{
|
{
|
||||||
await _graphService.Clear();
|
await _graphService.Clear();
|
||||||
_quizService.SetQuiz(null, null);
|
await _quizService.SetQuiz(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import * as d3 from 'https://esm.sh/d3@7';
|
import * as d3 from 'https://esm.sh/d3@7';
|
||||||
|
|
||||||
|
const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label;
|
||||||
|
const getPillWidth = d => getDisplayLabel(d).length * 8 + 30;
|
||||||
|
|
||||||
let simulation;
|
let simulation;
|
||||||
let zoomBehavior;
|
let zoomBehavior;
|
||||||
let svgElement;
|
let svgElement;
|
||||||
@@ -73,7 +76,7 @@ export function mount(containerId, data, dotNetHelper) {
|
|||||||
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
.force("link", d3.forceLink().id(d => d.id).distance(120))
|
||||||
.force("charge", d3.forceManyBody().strength(-400))
|
.force("charge", d3.forceManyBody().strength(-400))
|
||||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||||
.force("collide", d3.forceCollide().radius(50));
|
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
|
||||||
|
|
||||||
simulation.on("tick", () => {
|
simulation.on("tick", () => {
|
||||||
if (link) {
|
if (link) {
|
||||||
@@ -168,11 +171,11 @@ export function updateData(data) {
|
|||||||
|
|
||||||
g.append("rect")
|
g.append("rect")
|
||||||
.attr("class", "node-pill")
|
.attr("class", "node-pill")
|
||||||
.attr("x", d => -(d.label.length * 4 + 10))
|
.attr("x", d => -getPillWidth(d) / 2)
|
||||||
.attr("y", -12)
|
.attr("y", -15)
|
||||||
.attr("width", d => d.label.length * 8 + 20)
|
.attr("width", d => getPillWidth(d))
|
||||||
.attr("height", 24)
|
.attr("height", 30)
|
||||||
.attr("rx", 12)
|
.attr("rx", 15)
|
||||||
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
.attr("fill", "rgba(20, 20, 20, 0.9)")
|
||||||
.attr("stroke", d => {
|
.attr("stroke", d => {
|
||||||
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
if (d.type === 'Definition') return 'var(--nexus-accent)';
|
||||||
@@ -182,11 +185,14 @@ export function updateData(data) {
|
|||||||
.attr("stroke-width", 1);
|
.attr("stroke-width", 1);
|
||||||
|
|
||||||
g.append("text")
|
g.append("text")
|
||||||
.text(d => d.label)
|
.text(d => getDisplayLabel(d))
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("y", 4)
|
.attr("y", 5)
|
||||||
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
.attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc')
|
||||||
.attr("font-size", "0.8rem");
|
.attr("font-size", "0.8rem");
|
||||||
|
|
||||||
|
g.append("title")
|
||||||
|
.text(d => d.label);
|
||||||
|
|
||||||
g.transition().duration(500).style("opacity", 1);
|
g.transition().duration(500).style("opacity", 1);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user