Public Access
Strengthen workflow automation diagnostics
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingWorkflowDiagnosticEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReloadEndpointReloadsAutomationRulesPathFromConfigurationFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var configPath = Path.Combine(root, "workflow-config.json");
|
||||
var firstRulesPath = Path.Combine(root, "first-rules.yaml");
|
||||
var secondRulesPath = Path.Combine(root, "second-rules.yaml");
|
||||
await File.WriteAllTextAsync(configPath, CreateConfigJson(firstRulesPath));
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddJsonFile(configPath, optional: false, reloadOnChange: false);
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var firstResponse = await client.PostAsync("/diagnostics/workflow/reload", null);
|
||||
var firstBody = await firstResponse.Content.ReadFromJsonAsync<WorkflowReloadResponse>();
|
||||
await File.WriteAllTextAsync(configPath, CreateConfigJson(secondRulesPath));
|
||||
|
||||
using var secondResponse = await client.PostAsync("/diagnostics/workflow/reload", null);
|
||||
var secondBody = await secondResponse.Content.ReadFromJsonAsync<WorkflowReloadResponse>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(firstRulesPath, firstBody?.RulesPath);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
Assert.Equal(secondRulesPath, secondBody?.RulesPath);
|
||||
}
|
||||
|
||||
private static string CreateConfigJson(string rulesPath)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
MeetingAssistant = new
|
||||
{
|
||||
Automation = new
|
||||
{
|
||||
RulesPath = rulesPath
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record WorkflowReloadResponse(string? RulesPath);
|
||||
}
|
||||
@@ -137,6 +137,466 @@ public sealed class MeetingWorkflowEngineTests
|
||||
Assert.Equal(["Ada"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("created: {}", "created", null, null, null, true)]
|
||||
[InlineData("created: {}", "speaker", null, null, "Ada", false)]
|
||||
[InlineData("state_transition:\n from: collecting metadata", "state", "collecting metadata", "speaker recognition", null, true)]
|
||||
[InlineData("state_transition:\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("state_transition:\n from: collecting metadata\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("state_transition:\n from: transcribing\n to: summarizing", "state", "collecting metadata", "transcribing", null, false)]
|
||||
[InlineData("state_transition:\n from: COLLECTING METADATA\n to: TRANSCRIBING", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)]
|
||||
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)]
|
||||
[InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)]
|
||||
public async Task TriggerMatchingScenarios(
|
||||
string triggerYaml,
|
||||
string eventKind,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker,
|
||||
bool shouldRun)
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
$$"""
|
||||
rules:
|
||||
- name: trigger-scenario
|
||||
on:
|
||||
- {{triggerYaml}}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Triggered
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("event.type = 'SpeakerIdentified'", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("state.from = 'collecting metadata' and state.to = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("meeting.state = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("speaker.name = 'Ada'", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("speaker.name = 'Ada'", "speaker", null, null, "Grace", false)]
|
||||
[InlineData("starts_with(meeting.title, 'planning')", "created", null, null, null, true)]
|
||||
[InlineData("ends_with(meeting.title, 'sync')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.title, 'ANNING S')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.attendees, 'Ada')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.projects, 'Architecture')", "created", null, null, null, true)]
|
||||
[InlineData("meeting.attendees.count >= 2", "created", null, null, null, true)]
|
||||
public async Task ExpressionConditionParameterScenarios(
|
||||
string condition,
|
||||
string eventKind,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker,
|
||||
bool shouldRun)
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
$$"""
|
||||
rules:
|
||||
- name: condition-scenario
|
||||
on:
|
||||
- created: {}
|
||||
- state_transition: {}
|
||||
- speaker_identified: {}
|
||||
if:
|
||||
- condition: {{condition}}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ConditionMatched
|
||||
""",
|
||||
title: "Planning Sync",
|
||||
attendees: ["Ada", "Grace"],
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("ConditionMatched"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TopLevelConditionsAreCombinedWithAnd()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: all-top-level-conditions
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: contains(meeting.title, 'Planning')
|
||||
- condition: contains(meeting.attendees, 'Ada')
|
||||
- condition: contains(meeting.projects, 'Missing')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ShouldNotRun
|
||||
""",
|
||||
attendees: ["Ada"],
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrGroupSkipsRuleWhenAllBranchesAreFalse()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: false-or
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- or:
|
||||
- condition: contains(meeting.title, 'Budget')
|
||||
- condition: contains(meeting.attendees, 'Grace')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ShouldNotRun
|
||||
""",
|
||||
attendees: ["Ada"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyConditionListRunsWhenTriggerMatches()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: no-if
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: NoConditionNeeded
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Contains("NoConditionNeeded", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleWithMultipleTriggersRunsWhenAnyTriggerMatches()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: multi-trigger
|
||||
on:
|
||||
- created: {}
|
||||
- speaker_identified:
|
||||
name: Ada
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: MultiTrigger
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Contains("MultiTrigger", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleRulesRunInFileOrderAndLaterRulesSeeEarlierMutations()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: first
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
- name: second
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: contains(meeting.attendees, 'Ada')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Architecture
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada"], meeting.Frontmatter.Attendees);
|
||||
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaterStepsInSameRuleSeeEarlierStepMutationsInRazorModel()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: step-order
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
- uses: add_context
|
||||
value: 'Attendees: @Model.Meeting.Attendees.Count'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Attendees: 1", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerIdentifiedTemplateCanUseRecognizedSpeakerName()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: speaker-template
|
||||
on:
|
||||
- speaker_identified: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: '@Model.Speaker.Name joined @Model.Meeting.Title'
|
||||
""",
|
||||
title: "Design Review");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Manuel"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Manuel joined Design Review", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateTransitionTemplateCanUseFromAndToStates()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: transition-template
|
||||
on:
|
||||
- state_transition:
|
||||
to: transcribing
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: '@Model.Event.From -> @Model.Event.To'
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.StateTransition(
|
||||
fixture.Artifacts,
|
||||
AssistantContextState.CollectingMetadata,
|
||||
AssistantContextState.Transcribing),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("collecting metadata -> transcribing", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttendeeNormalizesDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: normalize-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: 'Ada Lovelace <ada@example.com>'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: dedupe-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
""",
|
||||
attendees: ["Ada <ada@example.com>"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada <ada@example.com>"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAttendeeMatchesExistingDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: remove-email-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: remove_attendee
|
||||
value: Ada
|
||||
""",
|
||||
attendees: ["Ada <ada@example.com>", "Grace"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Grace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddProjectDoesNotDuplicateCaseInsensitiveProject()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: dedupe-project
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: architecture
|
||||
""",
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPropertyCanUseNameAliasForTitle()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: title-by-name
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: set_property
|
||||
name: meeting.title
|
||||
value: Updated Title
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal("Updated Title", meeting.Frontmatter.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddContextDoesNotRequireMeetingNoteMutationToPersist()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: context-only
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: Context-only note.
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Context-only note.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesFileChangesAreReadForTheNextWorkflowEvent()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
Directory.CreateDirectory(root);
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: first-version
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: First
|
||||
""",
|
||||
rulesPath: rulesPath);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
await File.WriteAllTextAsync(
|
||||
rulesPath,
|
||||
"""
|
||||
rules:
|
||||
- name: second-version
|
||||
on:
|
||||
- speaker_identified: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Second
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["First", "Second"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlankRulesFileDoesNothing()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(" ");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Empty(meeting.Frontmatter.Attendees);
|
||||
Assert.Empty(meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingRulesFileDoesNothing()
|
||||
@@ -152,6 +612,47 @@ public sealed class MeetingWorkflowEngineTests
|
||||
Assert.Empty(meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
private static Task RunCreatedAsync(WorkflowFixture fixture)
|
||||
{
|
||||
return fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private static MeetingWorkflowEvent CreateEvent(
|
||||
string eventKind,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker)
|
||||
{
|
||||
return eventKind switch
|
||||
{
|
||||
"created" => MeetingWorkflowEvent.Created(artifacts),
|
||||
"speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"),
|
||||
"state" => MeetingWorkflowEvent.StateTransition(
|
||||
artifacts,
|
||||
ParseState(from ?? "collecting metadata"),
|
||||
ParseState(to ?? "transcribing")),
|
||||
_ => throw new InvalidOperationException($"Unknown workflow event kind '{eventKind}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static AssistantContextState ParseState(string state)
|
||||
{
|
||||
return state.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"collecting metadata" => AssistantContextState.CollectingMetadata,
|
||||
"transcribing" => AssistantContextState.Transcribing,
|
||||
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
||||
"summarizing" => AssistantContextState.Summarizing,
|
||||
"finished" => AssistantContextState.Finished,
|
||||
"error" => AssistantContextState.Error,
|
||||
_ => throw new InvalidOperationException($"Unknown workflow state '{state}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WorkflowFixture
|
||||
{
|
||||
private WorkflowFixture(
|
||||
@@ -178,6 +679,7 @@ public sealed class MeetingWorkflowEngineTests
|
||||
string rulesYaml,
|
||||
string title = "Planning Sync",
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
IReadOnlyList<string>? projects = null,
|
||||
string? rulesPath = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
@@ -219,6 +721,7 @@ public sealed class MeetingWorkflowEngineTests
|
||||
title,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
attendees: attendees ?? [],
|
||||
projects: projects ?? [],
|
||||
transcriptPath: artifacts.TranscriptPath,
|
||||
assistantContextPath: artifacts.AssistantContextPath,
|
||||
summaryPath: artifacts.SummaryPath) with
|
||||
@@ -242,5 +745,15 @@ public sealed class MeetingWorkflowEngineTests
|
||||
NullLogger<MeetingWorkflowEngine>.Instance);
|
||||
return new WorkflowFixture(options, noteStore, artifacts, engine);
|
||||
}
|
||||
|
||||
public Task<MeetingNote> ReadMeetingAsync()
|
||||
{
|
||||
return NoteStore.ReadAsync(Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task<string> ReadContextAsync()
|
||||
{
|
||||
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,22 @@ static async Task<IResult> MergeSpeakerIdentitiesAsync(
|
||||
: profileOptions.SpeakerIdentification.MergeRecentIdentityAge;
|
||||
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
|
||||
}
|
||||
app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) =>
|
||||
Results.Ok(ReloadWorkflowConfiguration(configuration)));
|
||||
|
||||
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
|
||||
{
|
||||
if (configuration is not IConfigurationRoot root)
|
||||
{
|
||||
throw new InvalidOperationException("Application configuration does not support reload.");
|
||||
}
|
||||
|
||||
root.Reload();
|
||||
var options = new MeetingAssistantOptions();
|
||||
configuration.GetSection("MeetingAssistant").Bind(options);
|
||||
return new WorkflowConfigurationReloadResponse(options.Automation.RulesPath);
|
||||
}
|
||||
|
||||
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.ToggleAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/recording/toggle", async (
|
||||
@@ -456,3 +472,5 @@ public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null);
|
||||
public sealed record SummaryRetryRequest(string SummaryPath);
|
||||
|
||||
public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null);
|
||||
|
||||
public sealed record WorkflowConfigurationReloadResponse(string? RulesPath);
|
||||
|
||||
@@ -230,11 +230,11 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
switch (step.Uses.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "add_attendee":
|
||||
return AddUnique(meeting.Frontmatter.Attendees, value);
|
||||
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
|
||||
case "remove_attendee":
|
||||
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
||||
case "add_project":
|
||||
return AddUnique(meeting.Frontmatter.Projects, value);
|
||||
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
|
||||
case "set_property":
|
||||
return SetProperty(meeting, step.Property ?? step.Name, value);
|
||||
case "add_context":
|
||||
@@ -283,11 +283,17 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
throw new InvalidOperationException($"Unsupported meeting workflow property '{property}'.");
|
||||
}
|
||||
|
||||
private static bool AddUnique(List<string> values, string value)
|
||||
private static bool AddUnique(
|
||||
List<string> values,
|
||||
string value,
|
||||
Func<string, string> normalize)
|
||||
{
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
|
||||
var normalized = normalize(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
values.Any(existing => string.Equals(existing, normalized, StringComparison.OrdinalIgnoreCase)))
|
||||
values.Any(existing => string.Equals(
|
||||
normalize(existing),
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ POST /recording/start
|
||||
POST /recording/stop
|
||||
POST /asr/transcribe-file
|
||||
POST /asr/diarize-file
|
||||
POST /diagnostics/workflow/reload
|
||||
POST /meetings/current/summary/run
|
||||
POST /meetings/summary/retry
|
||||
GET /meetings/summary/retry?summaryPath=...
|
||||
@@ -214,6 +215,8 @@ Assistant context notes keep their artifact links in frontmatter and expose a li
|
||||
|
||||
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
|
||||
|
||||
`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options.
|
||||
|
||||
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
|
||||
|
||||
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget.
|
||||
|
||||
@@ -22,6 +22,16 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
|
||||
|
||||
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
|
||||
|
||||
`POST /diagnostics/workflow/reload` reloads application configuration without restarting the process. Use it after changing workflow automation configuration such as `MeetingAssistant:Automation:RulesPath`. The endpoint returns the currently bound rules path:
|
||||
|
||||
```json
|
||||
{
|
||||
"rulesPath": "meeting-rules.local.yaml"
|
||||
}
|
||||
```
|
||||
|
||||
The YAML rules file itself is read for every workflow event, so changing the contents of the same rules file does not require this endpoint. Use the endpoint when the application configuration changes, for example when pointing `RulesPath` to a different YAML file. A meeting run that already captured its options may continue with those options; future runs and future configuration reads use the reloaded configuration.
|
||||
|
||||
## Execution Model
|
||||
|
||||
The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow event:
|
||||
@@ -291,6 +301,7 @@ Core files:
|
||||
- `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs`
|
||||
- `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowDiagnosticEndpointTests.cs`
|
||||
|
||||
When extending the workflow engine:
|
||||
|
||||
@@ -299,4 +310,3 @@ When extending the workflow engine:
|
||||
3. Keep the supported trigger, condition, template model, and step lists in this document current.
|
||||
4. Keep `README.md` as the short operational overview and link back here for details.
|
||||
5. Keep the local rules file ignored by git; do not commit personal automation rules.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ The local rules file SHALL be ignored by source control.
|
||||
|
||||
Rules SHALL be evaluated against the latest meeting note data read from disk for each event.
|
||||
|
||||
Meeting Assistant SHALL expose a diagnostic endpoint that reloads application configuration so workflow automation configuration changes can be picked up without restarting the application.
|
||||
|
||||
#### Scenario: Missing rules file is ignored
|
||||
- **WHEN** Meeting Assistant handles a meeting event and no configured rules file exists
|
||||
- **THEN** it leaves the meeting note and assistant context unchanged
|
||||
@@ -17,6 +19,12 @@ Rules SHALL be evaluated against the latest meeting note data read from disk for
|
||||
- **WHEN** Meeting Assistant creates a meeting note without attendees
|
||||
- **THEN** it adds the configured attendee to the meeting note
|
||||
|
||||
#### Scenario: Workflow configuration is reloaded diagnostically
|
||||
- **GIVEN** the workflow automation configuration has changed on disk
|
||||
- **WHEN** the diagnostic workflow reload endpoint is called
|
||||
- **THEN** Meeting Assistant reloads application configuration without restarting
|
||||
- **AND** future workflow events use the reloaded workflow automation configuration
|
||||
|
||||
### Requirement: Meeting automation rules support lifecycle triggers
|
||||
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, and `speaker_identified`.
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
- [x] 1.4 Render step values with Razor syntax.
|
||||
- [x] 1.5 Apply supported steps to meeting notes and assistant context.
|
||||
- [x] 1.6 Fire rules from meeting creation, state transitions, and speaker identification.
|
||||
- [x] 1.7 Add a diagnostic workflow configuration reload endpoint.
|
||||
|
||||
## 2. Verification
|
||||
- [x] 2.1 Cover created, state-transition, and speaker-identified paths.
|
||||
- [x] 2.2 Cover nested condition logic and templated values.
|
||||
- [x] 2.3 Run relevant tests and strict OpenSpec validation.
|
||||
- [x] 2.4 Cover diagnostic workflow configuration reload behavior.
|
||||
- [x] 2.5 Cover expanded workflow trigger, condition, parameter, templating, ordering, step, and rules-file reload scenarios.
|
||||
|
||||
Reference in New Issue
Block a user