Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Task/244054/Recommendation Chunk Text option on QA Visualiser #966

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cf2fc2b
GetPaginatedEntities method, QA Visualiser ability to include recomme…
jag-nahl-airelogic Feb 17, 2025
cd057d4
Unit + python test fixes/additions
jag-nahl-airelogic Feb 19, 2025
20dec91
Updated readme with new env variable
jag-nahl-airelogic Feb 19, 2025
540f0bb
chore: Linted code for plan-technology-for-your-school.sln solution
github-actions[bot] Feb 19, 2025
45e0aa8
Unit test fix and removed redundant code.
jag-nahl-airelogic Feb 19, 2025
59fe527
Merge remote-tracking branch 'origin/task/244054/recommendations-qa-w…
jag-nahl-airelogic Feb 19, 2025
55ec613
removed variable not used in fetch_sections.py
jag-nahl-airelogic Feb 19, 2025
868158f
chore: Linted code for qa-visualiser
github-actions[bot] Feb 19, 2025
c272b12
Merge branch 'development' into task/244054/recommendations-qa-workflow
jag-nahl-airelogic Feb 19, 2025
2394887
SonarQube fixes
jag-nahl-airelogic Feb 19, 2025
b59ceab
Merge remote-tracking branch 'origin/task/244054/recommendations-qa-w…
jag-nahl-airelogic Feb 19, 2025
defb6cf
chore: Linted code for plan-technology-for-your-school.sln solution
github-actions[bot] Feb 19, 2025
5dbc4a8
More unit test coverage
jag-nahl-airelogic Feb 19, 2025
8744c7a
Merge remote-tracking branch 'origin/task/244054/recommendations-qa-w…
jag-nahl-airelogic Feb 19, 2025
f7065a9
chore: Linted code for plan-technology-for-your-school.sln solution
github-actions[bot] Feb 19, 2025
766dc39
Added CMS Controller Unit tests
jag-nahl-airelogic Feb 20, 2025
4282645
Merge remote-tracking branch 'origin/task/244054/recommendations-qa-w…
jag-nahl-airelogic Feb 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/qa-viz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
type: choice
options: ['Dev', 'Tst', 'Staging', 'Production']
default: 'Staging'
recommendations:
type: boolean
description: Include recommendations
required: false
default: false
workflow_call:
inputs:
environment:
Expand Down Expand Up @@ -106,6 +111,7 @@ jobs:
PLANTECH_API_URL: https://localhost:8081/api/cms
PLANTECH_API_KEY: ${{ steps.get-plantech-api-key.outputs.secret_value }}
REQUESTS_CA_BUNDLE: localhost.crt
DISPLAY_RECOMMENDATIONS: ${{ inputs.recommendations }}

- name: Remove Azure firewall rules
uses: ./.github/actions/azure-ip-whitelist
Expand Down
1 change: 1 addition & 0 deletions contentful/qa-visualiser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ To run the qa-visualiser locally:
|------------------|--------------------------------------------------------|----------------------------------------------------|
| PLANTECH_API_KEY | The API key the cms controller uses for authentication | Keyvault value for `api--authentication--keyvalue` |
| PLANTECH_API_URL | Base url of the cms controller | https://localhost:8080/api/cms |
| DISPLAY_RECOMMENDATIONS | Boolean option to display recommendation header text linked to the answer on the image | true/false |

4. create and activate a virtual environment with:
```bash
Expand Down
14 changes: 12 additions & 2 deletions contentful/qa-visualiser/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import os

from src.fetch_sections import fetch_sections
from src.fetch_sections import fetch_recommendation_chunks, fetch_sections
from src.generate_visualisations import process_sections

logging.basicConfig(
Expand All @@ -10,8 +11,17 @@


def main():
display_recommendations = os.getenv("DISPLAY_RECOMMENDATIONS", "false").lower() in [
"true",
"1",
]
recommendation_map = []

if display_recommendations:
recommendation_map = fetch_recommendation_chunks()

sections = fetch_sections()
process_sections(sections)
process_sections(sections, recommendation_map)


if __name__ == "__main__":
Expand Down
67 changes: 67 additions & 0 deletions contentful/qa-visualiser/src/fetch_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,70 @@ def fetch_sections() -> list[Section]:
except (ValidationError, TypeError) as ex:
logger.error(f"Error converting response to Sections: {ex}")
raise ex


def fetch_recommendation_chunks() -> dict[str, list[str]]:
"""
Fetches all RecommendationChunks from the chunks API in /api/cms
Returns a dictionary mapping answerId to RecommendationHeader.
"""
token = os.getenv("PLANTECH_API_KEY")
base_url = os.getenv("PLANTECH_API_URL")

total_items = [] # Store all results
page_number = 1 # Start from the first page

try:
logger.info(f"Fetching recommendation chunks from {base_url}/chunks/1")

while True:
response = requests.get(
f"{base_url}/chunks/{page_number}",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
},
)
response.raise_for_status()

data = response.json()

items = data.get("items", [])

if not items:
logger.info(
f"No more items on page {page_number}. Stopping pagination."
)
break

total_items.extend(items)
logger.info(
f"Retrieved {len(items)} items from page {page_number}, total so far: {len(total_items)}"
)

page_number += 1

recommendation_map = {}

for item in total_items:
answer_id = item.get("answerId")
recommendation_header = item.get(
"recommendationHeader"
) # Ensure correct field name

if answer_id and recommendation_header:
if answer_id not in recommendation_map:
recommendation_map[answer_id] = [] # Initialize as list

recommendation_map[answer_id].append(
recommendation_header
) # Store multiple recommendations

logger.info(
f"Successfully retrieved {len(recommendation_map)} unique answer mappings."
)
return recommendation_map

except (RequestException, TypeError) as ex:
logger.error(f"Error fetching recommendation chunks: {ex}")
return {}
83 changes: 75 additions & 8 deletions contentful/qa-visualiser/src/generate_visualisations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@ def _create_blank_digraph() -> Digraph:
format="png",
graph_attr={
"rankdir": "LR",
"beautify": "true",
"nodesep": "0.2",
"ranksep": "0.5",
"splines": "true",
},
edge_attr={
"arrowhead": "vee",
"arrowsize": "0.5",
"arrowsize": "0.7",
"minlen": "2",
},
)


def create_questionnaire_flowchart(section: Section) -> Digraph:
def create_questionnaire_flowchart(
section: Section, recommendation_map: dict[str, str]
) -> Digraph:
"""Create a graph of all possible paths you can take through a section"""
tree = _create_blank_digraph()

Expand Down Expand Up @@ -62,9 +67,23 @@ def create_questionnaire_flowchart(section: Section) -> Digraph:
gradient="200",
)

created_recommendation_nodes = {}

for answer in question.answers:
answer_text = _wrap_text(answer.text, 20)

answer_id = answer.sys.id
answer_node_id = f"ans_{answer_id}"

tree.node(
answer_node_id,
answer_text,
shape="ellipse",
style="filled",
fillcolor="lightgrey:white",
width="1.5",
)

tree.edge(current_question_id, answer_node_id) # Connect question to answer
if next_question := answer.next_question:
next_question_id = next_question.sys.id
tree.node(
Expand All @@ -75,20 +94,68 @@ def create_questionnaire_flowchart(section: Section) -> Digraph:
fillcolor="red:white",
width="2",
)
tree.edge(current_question_id, next_question_id, label=answer_text)

if answer.next_question:
next_question_id = answer.next_question.sys.id

tree.node(
next_question_id,
"Next Question",
shape="diamond",
style="filled",
fillcolor="blue:white",
width="2",
)
tree.edge(
answer_node_id, next_question_id
) # Connect answer to next question
else:
tree.edge(current_question_id, "end", label=answer_text)
tree.edge(answer_node_id, "end") # Connect answer to end

# Ensure answer.sys.id exists and is in recommendation_map
if answer_id in recommendation_map:
recommendations = recommendation_map[
answer_id
] # Get list of recommendations

for recommendation_text in recommendations:
wrapped_text = _wrap_text(recommendation_text, 20)

# Check if this recommendation text already has a node
if wrapped_text not in created_recommendation_nodes:
recommendation_node_id = f"rec_{hash(wrapped_text)}" # Use hash to create a unique ID
tree.node(
recommendation_node_id,
wrapped_text,
shape="note",
style="filled",
fillcolor="yellow:white",
width="2",
)
created_recommendation_nodes[wrapped_text] = (
recommendation_node_id # Store the node ID
)

# Connect answer to existing recommendation node
tree.edge(
answer_node_id,
created_recommendation_nodes[wrapped_text],
label="",
color="red",
)

return tree


def process_sections(sections: list[Section]) -> None:
def process_sections(
sections: list[Section], recommendation_map: dict[str, str]
) -> None:
"""Generates a graph for each section and saves it to the visualisations folder by section name"""
png_folder = Path("visualisations")
png_folder.mkdir(exist_ok=True)

for section in sections:
logger.info(f"Generating visualisation for section {section.name}")
output_file = Path(png_folder, section.name)
flowchart = create_questionnaire_flowchart(section)
flowchart = create_questionnaire_flowchart(section, recommendation_map)
flowchart.render(output_file, cleanup=True)
31 changes: 26 additions & 5 deletions contentful/qa-visualiser/tests/test_generate_visualisations.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
from unittest.mock import patch

from src.generate_visualisations import create_questionnaire_flowchart
from src.generate_visualisations import _wrap_text, create_questionnaire_flowchart
from src.models import Section


@patch("src.generate_visualisations.Digraph.node")
@patch("src.generate_visualisations.Digraph.edge")
def test_create_questionnaire_flowchart(mock_edge, mock_node, mock_sections):
mock_recommendation_map = {
"answer-1": ["Keep backups up-to-date"],
"answer-2": ["Ensure security protocols are followed"],
}

wrapped_recommendation_1 = _wrap_text("Keep backups up-to-date", 20)
wrapped_recommendation_2 = _wrap_text("Ensure security protocols are followed", 20)

rec_id_1 = f"rec_{hash(wrapped_recommendation_1)}"
rec_id_2 = f"rec_{hash(wrapped_recommendation_2)}"

expected_nodes = {
("question-1", "First Question?"),
("question-2", "Missing Content"),
("end", "Check Answers"),
("question-2", "Next Question"),
("ans_answer-1", "first answer"),
("ans_answer-2", "second answer"),
(rec_id_1, wrapped_recommendation_1),
(rec_id_2, wrapped_recommendation_2),
("question-2", "Missing Content"),
}
expected_edges = {
("question-1", "question-2"),
("question-1", "end"),
("question-1", "ans_answer-1"),
("question-1", "ans_answer-2"),
("ans_answer-1", "question-2"),
("ans_answer-2", "end"),
("ans_answer-1", rec_id_1),
("ans_answer-2", rec_id_2),
}

section = Section.model_validate(mock_sections[0])
create_questionnaire_flowchart(section)
create_questionnaire_flowchart(section, mock_recommendation_map)

assert mock_node.call_count == len(expected_nodes)
assert mock_edge.call_count == len(expected_edges)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@
/// <param name="cancellationToken"></param>
/// <typeparam name="TEntity"></typeparam>
/// <returns></returns>
Task<IEnumerable<TEntity>> GetEntities<TEntity>(IGetEntitiesOptions options, CancellationToken cancellationToken = default);

Check warning on line 37 in src/Dfe.PlanTech.Application/Persistence/Interfaces/IContentRepository.cs

View workflow job for this annotation

GitHub Actions / Build and run unit tests

All 'GetEntities' method overloads should be adjacent. (https://rules.sonarsource.com/csharp/RSPEC-4136)

Task<IEnumerable<TEntity>> GetPaginatedEntities<TEntity>(IGetEntitiesOptions options, CancellationToken cancellationToken = default);

Task<int> GetEntitiesCount<TEntity>(CancellationToken cancellationToken = default);

/// <summary>
/// Get entities without filtering
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public GetEntitiesOptions()
{
}

public int Page { get; init; } = 1;

public int? Limit { get; init; }

public IEnumerable<string>? Select { get; set; }

public IEnumerable<IContentQuery>? Queries { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Dfe.PlanTech.Application.Core;
using Dfe.PlanTech.Application.Exceptions;
using Dfe.PlanTech.Application.Persistence.Interfaces;
using Dfe.PlanTech.Application.Persistence.Models;
using Dfe.PlanTech.Domain.Common;
using Dfe.PlanTech.Domain.Questionnaire.Interfaces;
using Dfe.PlanTech.Domain.Questionnaire.Models;

namespace Dfe.PlanTech.Application.Questionnaire.Queries
{
public class GetRecommendationQuery : ContentRetriever, IGetRecommendationQuery
{

public GetRecommendationQuery(IContentRepository repository) : base(repository)
{
}

/// <summary>
/// Returns recommendation chunks from contentful but only containing the system details ID and the header.
/// </summary>
public async Task<(IEnumerable<RecommendationChunk> Chunks, Pagination Pagination)> GetChunksByPage(int page, CancellationToken cancellationToken = default)
{
try
{
var totalEntries = await repository.GetEntitiesCount<RecommendationChunk>(cancellationToken);

var options = new GetEntitiesOptions(include: 3) { Page = page };
var result = await repository.GetPaginatedEntities<RecommendationChunk>(options, cancellationToken);

return (result, new Pagination() { Page = page, Total = totalEntries });

}
catch (Exception ex)
{
throw new ContentfulDataUnavailableException("Error getting recommendation chunks from Contentful", ex);
}
}
}
}
8 changes: 8 additions & 0 deletions src/Dfe.PlanTech.Domain/Common/Pagination.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Dfe.PlanTech.Domain.Common
{
public class Pagination
{
public int Total { get; set; }
public int Page { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ public interface IGetEntitiesOptions
/// </remarks>
public IEnumerable<string>? Select { get; set; }

public int? Limit { get; init; }
public int Page { get; init; }

public string SerializeToRedisFormat();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Dfe.PlanTech.Domain.Common;
using Dfe.PlanTech.Domain.Questionnaire.Models;

namespace Dfe.PlanTech.Domain.Questionnaire.Interfaces
{
public interface IGetRecommendationQuery
{
public Task<(IEnumerable<RecommendationChunk> Chunks, Pagination Pagination)> GetChunksByPage(int page, CancellationToken cancellationToken = default);

}
}
Loading
Loading